Zustand State Management
Quick reference for managing global state with Zustand in your React application.
When to Use Zustand vs useState
Use useState for:
- Form inputs and local component state
- Data that only one component needs
- Temporary UI states (loading, error in one component)
Use Zustand for:
- Data shared between multiple components
- User authentication state
- Application settings and preferences
- Complex state that needs computed values
- Data that survives component unmounting
tip
For comprehensive documentation, see the Zustand documentation
Why should I use a State Management ?
- Centralized state → All shared data lives in one predictable place, avoiding "prop drilling" and inconsistent state spread across components
- Improved maintainability → Easier to reason about, test, and debug since the logic for updating state is separated from UI components
- Scalable patterns → Works well for small apps (just a few states) and large apps (complex stores, middleware), so you don’t need to migrate later
- Predictable mutations → State changes go through well-defined functions, reducing side effects and unexpected behavior
- Better developer experience → Encourages clean separation between business logic and presentation
How do I create a basic store?
Common Zustand Mistakes
- Never mutate state directly - Always return new objects/arrays
- Use devtools in development - Makes debugging much easier
- Don't put everything in one store - Split by domain (user, tasks, etc.)
- Be careful with computed values - They run on every access, not just when dependencies change
- Don't put functions in state - Only serializable data
Simple counter store:
// stores/useCounterStore.ts
import { create } from 'zustand'
import { devtools } from 'zustand/middleware'
interface CounterState {
count: number // Store data, can be anything serializable (✅: number, objects, API's DTO... ❌: function, buffer...)
increment: () => void // Actions to edit the store
decrement: () => void
incrementBy: (amount: number) => void
reset: () => void
}
export const useCounterStore = create<CounterState>()(
devtools( // See: https://zustand.docs.pmnd.rs/middlewares/devtools
(set, get) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
decrement: () => set((state) => ({ count: state.count - 1 })),
incrementBy: (amount) => set((state) => ({ count: state.count + amount })),
reset: () => set({ count: 0 }),
}),
)
)
// Usage in component
export function Counter() {
const { count, increment, decrement, reset } = useCounterStore()
return (
<div className="flex items-center gap-2">
<button onClick={decrement} className="px-3 py-1 bg-red-500 text-white rounded">
-
</button>
<span className="px-4 py-2 bg-gray-100 rounded">{count}</span>
<button onClick={increment} className="px-3 py-1 bg-green-500 text-white rounded">
+
</button>
<button onClick={reset} className="px-3 py-1 bg-gray-500 text-white rounded ml-2">
Reset
</button>
</div>
)
}
State Mutation Rules
✅ Correct - Return new objects:
set((state) => ({ count: state.count + 1 })) // Good
set({ tasks: [...oldTasks, newTask] }) // Good
❌ Wrong - Mutating existing state:
state.count += 1; return state // Bad
tasks.push(newTask); set({ tasks }) // Bad
state.user.name = 'New Name'; return state // Bad
Why? React uses reference equality to detect changes. If you mutate the existing object, React thinks nothing changed and won't re-render.
How do I manage complex data structures?
Store Organization
Good store structure:
- Group related state together (tasks, loading, error)
- Keep actions close to the data they modify
- Add computed values (getters) for derived data
Avoid:
- Giant stores with everything mixed together
- Deeply nested state (keep it flat when possible)
- Storing non-serializable data (functions, DOM nodes)
- Duplicate data (e.g.,
tasksandselectedTask, preferselectedTaskId)
Task management store:
// stores/useTaskStore.ts
import { create } from 'zustand'
import { devtools, persist } from 'zustand/middleware'
import type { TaskDTO, CreateTaskDTO } from '@/types/openapi'
interface TaskState {
// State
tasks: TaskDTO[]
loading: boolean
error: string | null
selectedTaskId: string | null
filters: {
completed?: boolean
priority?: 'low' | 'medium' | 'high'
search?: string
}
// Actions
setTasks: (tasks: TaskDTO[]) => void
addTask: (task: TaskDTO) => void
updateTask: (taskId: string, updates: Partial<TaskDTO>) => void
removeTask: (taskId: string) => void
setLoading: (loading: boolean) => void
setError: (error: string | null) => void
setSelectedTask: (taskId: string | null) => void
setFilters: (filters: Partial<TaskState['filters']>) => void
clearFilters: () => void
// Computed values (selectors)
getFilteredTasks: () => TaskDTO[]
getSelectedTask: () => TaskDTO | undefined
getTasksByStatus: (completed: boolean) => TaskDTO[]
getTaskStats: () => {
total: number
completed: number
pending: number
completionRate: number
}
}
export const useTaskStore = create<TaskState>()(
devtools(
persist(
(set, get) => ({
// Initial state
tasks: [],
loading: false,
error: null,
selectedTaskId: null,
filters: {},
// Actions
setTasks: (tasks) => set({ tasks }, false, 'setTasks'),
addTask: (task) => set(
(state) => ({ tasks: [...state.tasks, task] }),
false,
'addTask'
),
updateTask: (taskId, updates) => set(
(state) => ({
tasks: state.tasks.map(task =>
task.id === taskId ? { ...task, ...updates } : task
)
}),
false,
'updateTask'
),
removeTask: (taskId) => set(
(state) => ({
tasks: state.tasks.filter(task => task.id !== taskId),
selectedTaskId: state.selectedTaskId === taskId ? null : state.selectedTaskId
}),
false,
'removeTask'
),
setLoading: (loading) => set({ loading }, false, 'setLoading'),
setError: (error) => set({ error }, false, 'setError'),
setSelectedTask: (selectedTaskId) => set({ selectedTaskId }, false, 'setSelectedTask'),
setFilters: (newFilters) => set(
(state) => ({ filters: { ...state.filters, ...newFilters } }),
false,
'setFilters'
),
clearFilters: () => set({ filters: {} }, false, 'clearFilters'),
// Computed values
getFilteredTasks: () => {
const { tasks, filters } = get()
return tasks.filter(task => {
if (filters.completed !== undefined && task.completed !== filters.completed) {
return false
}
if (filters.priority && task.priority !== filters.priority) {
return false
}
if (filters.search && !task.title.toLowerCase().includes(filters.search.toLowerCase())) {
return false
}
return true
})
},
getSelectedTask: () => {
const { tasks, selectedTaskId } = get()
return tasks.find(task => task.id === selectedTaskId)
},
getTasksByStatus: (completed) => {
const { tasks } = get()
return tasks.filter(task => task.completed === completed)
},
getTaskStats: () => {
const { tasks } = get()
const total = tasks.length
const completed = tasks.filter(task => task.completed).length
const pending = total - completed
const completionRate = total > 0 ? Math.round((completed / total) * 100) : 0
return { total, completed, pending, completionRate }
},
}),
{
name: 'task-store', // localStorage key
partialize: (state) => ({
// Only persist certain fields (not loading/error states)
tasks: state.tasks,
filters: state.filters,
selectedTaskId: state.selectedTaskId
})
}
),
{ name: 'task-store' }
)
)
How do I integrate with API calls?
Async Actions Best Practices
- Always handle loading states - Users need feedback
- Handle errors gracefully - Don't let errors crash the app
Store with API integration:
// stores/useTaskStoreWithAPI.ts
import { create } from 'zustand'
import { devtools } from 'zustand/middleware'
import { apiClient } from '@/types/openapi'
import type { TaskDTO, CreateTaskDTO, UpdateTaskDTO } from '@/types/openapi'
interface TaskStoreWithAPI extends TaskState {
// API actions
fetchTasks: () => Promise<void>
}
export const useTaskStoreWithAPI = create<TaskStoreWithAPI>()(
devtools(
(set, get) => ({
// Include all basic store functionality
...useTaskStore.getState(),
// API actions
fetchTasks: async () => {
set({ loading: true, error: null }, false, 'fetchTasks:start')
try {
const { data, error } = await apiClient.GET('/tasks/my-tasks')
if (error) {
set({ error: 'Failed to fetch tasks', loading: false }, false, 'fetchTasks:error')
return
}
set({ tasks: data || [], loading: false, error: null }, false, 'fetchTasks:success')
} catch (err) {
set({
error: 'Network error fetching tasks',
loading: false
}, false, 'fetchTasks:networkError')
}
},
}),
)
)
How do I create computed values (selectors)?
Store with computed values:
// stores/useUserStore.ts
import { create } from 'zustand'
import { devtools } from 'zustand/middleware'
interface User {
id: string
name: string
email: string
role: 'admin' | 'user' | 'moderator'
preferences: {
theme: 'light' | 'dark'
notifications: boolean
language: string
}
}
interface UserState {
user: User | null
isAuthenticated: boolean
// Actions
setUser: (user: User) => void
updatePreferences: (preferences: Partial<User['preferences']>) => void
logout: () => void
// Computed values
isAdmin: () => boolean
isModerator: () => boolean
canModerate: () => boolean
getDisplayName: () => string
getInitials: () => string
}
export const useUserStore = create<UserState>()(
devtools(
(set, get) => ({
user: null,
isAuthenticated: false,
setUser: (user) => set({ user, isAuthenticated: true }, false, 'setUser'),
updatePreferences: (newPreferences) => set(
(state) => ({
user: state.user ? {
...state.user,
preferences: { ...state.user.preferences, ...newPreferences }
} : state.user
}),
false,
'updatePreferences'
),
logout: () => set({ user: null, isAuthenticated: false }, false, 'logout'),
// Computed values
isAdmin: () => get().user?.role === 'admin',
isModerator: () => get().user?.role === 'moderator',
canModerate: () => {
const role = get().user?.role
return role === 'admin' || role === 'moderator'
},
getDisplayName: () => {
const user = get().user
return user?.name || user?.email || 'Anonymous'
},
getInitials: () => {
const user = get().user
if (!user?.name) return '??'
return user.name
.split(' ')
.map(part => part[0])
.slice(0, 2)
.join('')
.toUpperCase()
},
})
)
)
// Usage in components
export function UserProfileCard() {
const {
user,
isAuthenticated,
getDisplayName,
getInitials,
isAdmin,
updatePreferences
} = useUserStore()
if (!isAuthenticated || !user) {
return <div>Not authenticated</div>
}
return (
<div className="bg-white rounded-lg p-4 shadow">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 bg-primary-500 text-white rounded-full flex items-center justify-center font-semibold">
{getInitials()}
</div>
<div>
<h3 className="font-semibold">{getDisplayName()}</h3>
<p className="text-sm text-gray-600">{user.email}</p>
{isAdmin() && <span className="text-xs bg-red-100 text-red-800 px-2 py-1 rounded">Admin</span>}
</div>
</div>
<div className="space-y-2">
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={user.preferences.notifications}
onChange={(e) => updatePreferences({ notifications: e.target.checked })}
/>
<span className="text-sm">Email notifications</span>
</label>
<div className="flex items-center gap-2">
<span className="text-sm">Theme:</span>
<select
value={user.preferences.theme}
onChange={(e) => updatePreferences({ theme: e.target.value as 'light' | 'dark' })}
className="text-sm border rounded px-2 py-1"
>
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
</div>
</div>
</div>
)
}
How do I persist store data?
Persistence Gotchas
Don't persist:
- Loading/error states (temporary)
- Sensitive data (passwords, tokens)
- Large objects (images, files)
- Non-serializable data (functions, dates as objects)
Use partialize to control what gets saved:
partialize: (state) => ({
userPrefs: state.userPrefs, // ✅ Persist
// loading: state.loading, // ❌ Don't persist
// authToken: state.token // ❌ Security risk
})
Persistence with localStorage/sessionStorage:
// stores/usePersistedStore.ts
import { create } from 'zustand'
import { persist, createJSONStorage } from 'zustand/middleware'
interface PersistedState {
userPreferences: {
theme: 'light' | 'dark'
language: string
notifications: boolean
sidebarCollapsed: boolean
}
recentItems: string[]
bookmarks: string[]
// Actions
updatePreferences: (prefs: Partial<PersistedState['userPreferences']>) => void
addRecentItem: (item: string) => void
addBookmark: (item: string) => void
removeBookmark: (item: string) => void
clearRecent: () => void
}
export const usePersistedStore = create<PersistedState>()(
persist(
(set, get) => ({
userPreferences: {
theme: 'light',
language: 'en',
notifications: true,
sidebarCollapsed: false,
},
recentItems: [],
bookmarks: [],
updatePreferences: (prefs) => set(
(state) => ({
userPreferences: { ...state.userPreferences, ...prefs }
}),
false,
'updatePreferences'
),
addRecentItem: (item) => set(
(state) => ({
recentItems: [
item,
...state.recentItems.filter(i => i !== item).slice(0, 9)
]
}),
false,
'addRecentItem'
),
addBookmark: (item) => set(
(state) => ({
bookmarks: state.bookmarks.includes(item)
? state.bookmarks
: [...state.bookmarks, item]
}),
false,
'addBookmark'
),
removeBookmark: (item) => set(
(state) => ({
bookmarks: state.bookmarks.filter(b => b !== item)
}),
false,
'removeBookmark'
),
clearRecent: () => set({ recentItems: [] }, false, 'clearRecent'),
}),
{
name: 'app-preferences', // localStorage key
storage: createJSONStorage(() => localStorage),
// Partial persistence - only save specific fields
partialize: (state) => ({
userPreferences: state.userPreferences,
recentItems: state.recentItems.slice(0, 5), // Only keep 5 recent items
bookmarks: state.bookmarks
}),
// Migration for schema changes - IMPORTANT for production apps!
version: 2,
migrate: (persistedState: any, version: number) => {
if (version === 1) {
// Migrate from v1 to v2: added sidebarCollapsed field
return {
...persistedState,
userPreferences: {
...persistedState.userPreferences,
sidebarCollapsed: false // Default value for new field
}
}
}
return persistedState
// Without migration, users would lose their settings when you change the schema!
}
}
)
)
// Store that only persists to sessionStorage
export const useSessionStore = create<{
tempData: any
setTempData: (data: any) => void
}>()(
persist(
(set) => ({
tempData: null,
setTempData: (tempData) => set({ tempData }),
}),
{
name: 'session-data',
storage: createJSONStorage(() => sessionStorage),
}
)
)
How do I test Zustand stores?
Testing patterns:
// __tests__/stores/taskStore.test.ts
import { act, renderHook } from '@testing-library/react'
import { useTaskStore } from '@/stores/useTaskStore'
describe('Task Store', () => {
beforeEach(() => {
// Reset store before each test
useTaskStore.setState({
tasks: [],
loading: false,
error: null,
selectedTaskId: null,
filters: {}
})
})
it('should add a task', () => {
const { result } = renderHook(() => useTaskStore())
const newTask = {
id: '1',
title: 'Test task',
completed: false,
user_id: 'user1',
created_at: new Date().toISOString(),
updated_at: new Date().toISOString()
}
act(() => {
result.current.addTask(newTask)
})
expect(result.current.tasks).toHaveLength(1)
expect(result.current.tasks[0]).toEqual(newTask)
})
it('should filter tasks correctly', () => {
const { result } = renderHook(() => useTaskStore())
const tasks = [
{ id: '1', title: 'Task 1', completed: false, priority: 'high' },
{ id: '2', title: 'Task 2', completed: true, priority: 'low' },
{ id: '3', title: 'Task 3', completed: false, priority: 'high' }
]
act(() => {
result.current.setTasks(tasks as any)
result.current.setFilters({ completed: false })
})
const filtered = result.current.getFilteredTasks()
expect(filtered).toHaveLength(2)
expect(filtered.every(task => !task.completed)).toBe(true)
})
it('should calculate stats correctly', () => {
const { result } = renderHook(() => useTaskStore())
const tasks = [
{ id: '1', completed: false },
{ id: '2', completed: true },
{ id: '3', completed: true }
]
act(() => {
result.current.setTasks(tasks as any)
})
const stats = result.current.getTaskStats()
expect(stats.total).toBe(3)
expect(stats.completed).toBe(2)
expect(stats.pending).toBe(1)
expect(stats.completionRate).toBe(67)
})
})
// Integration test with components
import { render, screen, fireEvent } from '@testing-library/react'
import { Counter } from '@/components/Counter'
import { useCounterStore } from '@/stores/useCounterStore'
describe('Counter Integration', () => {
beforeEach(() => {
useCounterStore.setState({ count: 0 })
})
it('should increment counter when button is clicked', () => {
render(<Counter />)
const incrementButton = screen.getByText('+')
const countDisplay = screen.getByText('0')
fireEvent.click(incrementButton)
expect(screen.getByText('1')).toBeInTheDocument()
})
})
Performance & Troubleshooting
Performance Optimization
Prevent unnecessary re-renders:
// ❌ Bad - subscribes to entire store
const store = useTaskStore()
// ✅ Good - only subscribes to specific fields
const { tasks, loading } = useTaskStore(state => ({
tasks: state.tasks,
loading: state.loading
}))
// ✅ Even better - use selector functions
const tasks = useTaskStore(state => state.tasks)
const loading = useTaskStore(state => state.loading)
Common Issues
- "Component not re-rendering" → Check if you're mutating state instead of returning new objects
- "Too many re-renders" → Use specific selectors instead of entire store
- "State resets on page refresh" → Make sure persist middleware is properly configured
Always check the browser console and Redux DevTools for debugging clues