Zustand Best Practices: How to Structure Stores, Avoid Re-renders, and Keep Things Clean
You've built your first Zustand store. It works. Life is good.
Then your app grows. Your store gets bigger. You add more state. More actions. More complexity.
And suddenly, that simple 20-line store is now 200 lines. Components re-render when they shouldn't. Things feel messy.
I've been there. Let me share what I learned.
Pattern 1: Split Large Stores into Slices
The first thing that happens as your app grows is that your single store becomes a dumping ground for everything.
User data. Settings. Notifications. Cart. Theme. All in one giant object.
Don't do that.
The Problem
const useStore = create((set) => ({
// User stuff
user: null,
isAuthenticated: false,
login: () => {},
logout: () => {},
// Cart stuff
cartItems: [],
addToCart: () => {},
removeFromCart: () => {},
// Settings stuff
theme: 'light',
notifications: true,
updateSettings: () => {},
// ... 50 more lines
}))
This works, but it's messy. Everything updates everything. It's hard to find what you need.
The Solution: Separate Stores
// stores/useUserStore.js
export const useUserStore = create((set) => ({
user: null,
isAuthenticated: false,
login: async (credentials) => {
const user = await api.login(credentials)
set({ user, isAuthenticated: true })
},
logout: () => set({ user: null, isAuthenticated: false }),
}))
// stores/useCartStore.js
export const useCartStore = create((set) => ({
items: [],
addItem: (product) => set((state) => ({
items: [...state.items, product]
})),
removeItem: (id) => set((state) => ({
items: state.items.filter(item => item.id !== id)
})),
}))
// stores/useSettingsStore.js
export const useSettingsStore = create((set) => ({
theme: 'light',
notifications: true,
setTheme: (theme) => set({ theme }),
toggleNotifications: () => set((state) => ({
notifications: !state.notifications
})),
}))
Now each store has one responsibility. Easier to understand. Easier to maintain. Easier to test.
Pattern 2: Use Selectors to Prevent Re-renders
This is huge. And most people get it wrong at first.
When you do this:
function MyComponent() {
const store = useStore() // Gets entire store
return {store.user.name}
}
Your component re-renders whenever ANYTHING in the store changes. Even if you only use user.name.
Cart item added? Re-render. Theme changed? Re-render. Notification dismissed? Re-render.
The Fix: Select Only What You Need
function MyComponent() {
// Only subscribe to user.name
const userName = useStore((state) => state.user.name)
return {userName}
}
Now this component only re-renders when user.name changes. Nothing else.
For Multiple Values
function UserProfile() {
// Subscribe to multiple values
const { name, email, avatar } = useStore((state) => ({
name: state.user.name,
email: state.user.email,
avatar: state.user.avatar,
}))
return (
{name}
{email}
)
}
Zustand is smart enough to do a shallow comparison. If these three values don't change, the component doesn't re-render.
Pattern 3: Keep Actions Simple
I see this mistake a lot. People try to make their actions do too much.
Don't Do This
const useStore = create((set, get) => ({
todos: [],
addTodo: (text) => set((state) => {
// Too much logic in the action
const newTodo = {
id: Math.random().toString(36).substr(2, 9),
text: text.trim(),
done: false,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
priority: determinePriority(text),
tags: extractTags(text),
}
const updatedTodos = [...state.todos, newTodo]
// Side effects inside action
localStorage.setItem('todos', JSON.stringify(updatedTodos))
analytics.track('todo_added')
return { todos: updatedTodos }
}),
}))
This works, but it's doing too much. Hard to test. Hard to reason about.
Do This Instead
// utils/todoHelpers.js
export const createTodo = (text) => ({
id: Math.random().toString(36).substr(2, 9),
text: text.trim(),
done: false,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
priority: determinePriority(text),
tags: extractTags(text),
})
// stores/useTodoStore.js
const useTodoStore = create((set) => ({
todos: [],
addTodo: (text) => set((state) => ({
todos: [...state.todos, createTodo(text)]
})),
}))
// In your component
function addTodoHandler(text) {
useTodoStore.getState().addTodo(text)
localStorage.setItem('todos', JSON.stringify(useTodoStore.getState().todos))
analytics.track('todo_added')
}
Now the store action is simple. The helper function is testable. Side effects are in the component where you can see them.
Pattern 4: Computed Values
Sometimes you need derived state. Don't store it-compute it.
Don't Store Derived State
// Bad
const useCartStore = create((set) => ({
items: [],
total: 0, // Don't do this!
addItem: (item) => set((state) => {
const newItems = [...state.items, item]
const newTotal = newItems.reduce((sum, i) => sum + i.price, 0)
return { items: newItems, total: newTotal }
}),
}))
Compute It On Read
// Good
const useCartStore = create((set, get) => ({
items: [],
addItem: (item) => set((state) => ({
items: [...state.items, item]
})),
getTotal: () => {
const items = get().items
return items.reduce((sum, item) => sum + item.price, 0)
},
}))
// In component
function CartTotal() {
const getTotal = useCartStore((state) => state.getTotal)
const total = getTotal()
return Total: ${total}
}
Or even better, use a selector:
function CartTotal() {
const total = useCartStore((state) =>
state.items.reduce((sum, item) => sum + item.price, 0)
)
return Total: ${total}
}
This component only re-renders when the total changes, even if items are reordered.
Pattern 5: Organize Related Actions
Group related actions together. Makes your store easier to navigate.
const useUserStore = create((set) => ({
user: null,
isLoading: false,
error: null,
// Auth actions
auth: {
login: async (credentials) => {
set({ isLoading: true, error: null })
try {
const user = await api.login(credentials)
set({ user, isLoading: false })
} catch (error) {
set({ error: error.message, isLoading: false })
}
},
logout: () => set({ user: null }),
refresh: async () => {
const user = await api.getCurrentUser()
set({ user })
},
},
// Profile actions
profile: {
update: async (data) => {
const user = await api.updateProfile(data)
set({ user })
},
uploadAvatar: async (file) => {
const avatar = await api.uploadAvatar(file)
set((state) => ({ user: { ...state.user, avatar } }))
},
},
}))
// Usage
const { login } = useUserStore((state) => state.auth)
const { update } = useUserStore((state) => state.profile)
Clean. Organized. Easy to find what you need.
Pattern 6: TypeScript for Clarity
If you're using TypeScript, define your store interface. Makes everything clearer.
interface User {
id: string
name: string
email: string
}
interface UserStore {
user: User | null
isLoading: boolean
error: string | null
login: (credentials: { email: string; password: string }) => Promise
logout: () => void
updateProfile: (data: Partial) => Promise
}
const useUserStore = create((set) => ({
user: null,
isLoading: false,
error: null,
login: async (credentials) => {
set({ isLoading: true })
const user = await api.login(credentials)
set({ user, isLoading: false })
},
logout: () => set({ user: null }),
updateProfile: async (data) => {
const user = await api.updateProfile(data)
set({ user })
},
}))
Now you get autocomplete. Type safety. Fewer bugs.
Pattern 7: Middleware for Persistence
Need to save state to localStorage? Use the persist middleware.
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
const useSettingsStore = create(
persist(
(set) => ({
theme: 'light',
language: 'en',
setTheme: (theme) => set({ theme }),
setLanguage: (language) => set({ language }),
}),
{
name: 'app-settings', // localStorage key
partialize: (state) => ({
theme: state.theme,
language: state.language,
}), // Only persist these fields
}
)
)
Now settings persist across page refreshes. Automatic.
Pattern 8: Async Actions with Loading States
Handle async operations properly. Show loading. Handle errors.
const useDataStore = create((set) => ({
data: null,
isLoading: false,
error: null,
fetchData: async () => {
set({ isLoading: true, error: null })
try {
const data = await api.fetchData()
set({ data, isLoading: false })
} catch (error) {
set({ error: error.message, isLoading: false })
}
},
}))
// In component
function DataDisplay() {
const { data, isLoading, error, fetchData } = useDataStore()
useEffect(() => {
fetchData()
}, [])
if (isLoading) return
if (error) return
if (!data) return null
return {data.content}
}
Clear states. Easy to reason about. Works every time.
Pattern 9: Reset Functions
Sometimes you need to reset parts of your store.
const initialState = {
filters: {
search: '',
category: 'all',
sortBy: 'date',
},
}
const useProductStore = create((set) => ({
...initialState,
products: [],
setFilter: (key, value) => set((state) => ({
filters: { ...state.filters, [key]: value }
})),
resetFilters: () => set({ filters: initialState.filters }),
reset: () => set(initialState),
}))
Makes cleanup easy. Useful for testing too.
Common Mistakes to Avoid
Mistake 1: Subscribing to the Entire Store
// Bad - re-renders on any change
const store = useStore()
// Good - only re-renders when userName changes
const userName = useStore((state) => state.user.name)
Mistake 2: Creating New Objects in Selectors
// Bad - creates new object on every render, causes re-renders
const userInfo = useStore((state) => ({ name: state.name, email: state.email }))
// Good - use shallow equality or separate selectors
const name = useStore((state) => state.name)
const email = useStore((state) => state.email)
Mistake 3: Not Handling Async Errors
Always handle errors in async actions. Don't let them fail silently.
Mistake 4: Putting Too Much in One Store
If your store has more than 100 lines, consider splitting it.
Quick Checklist
Before you commit that store code, ask yourself:
- [ ] Is each store focused on one domain?
- [ ] Am I using selectors to prevent re-renders?
- [ ] Are my actions simple and focused?
- [ ] Am I computing derived state instead of storing it?
- [ ] Do I have proper loading and error states?
- [ ] Is the code readable and maintainable?
The Bottom Line
Good Zustand code is:
- Focused - One store, one responsibility
- Selective - Subscribe only to what you need
- Simple - Keep actions small and testable
- Clean - Organized, typed, and easy to navigate
Start with these patterns. Your future self will thank you.
And when someone new joins your project? They'll actually understand your state management. That's a win.
---
Questions about these patterns? Drop them in the comments. Happy to help!Want articles like this in your inbox?
Join developers and founders who get practical insights on frontend, SaaS, and building better products.
Written by Salman Izhar
Frontend Developer specializing in React, Next.js, and building high-converting web applications.
Learn More