Skip to main content

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
  1. Never mutate state directly - Always return new objects/arrays
  2. Use devtools in development - Makes debugging much easier
  3. Don't put everything in one store - Split by domain (user, tasks, etc.)
  4. Be careful with computed values - They run on every access, not just when dependencies change
  5. 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., tasks and selectedTask, prefer selectedTaskId)

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
  1. "Component not re-rendering" → Check if you're mutating state instead of returning new objects
  2. "Too many re-renders" → Use specific selectors instead of entire store
  3. "State resets on page refresh" → Make sure persist middleware is properly configured

Always check the browser console and Redux DevTools for debugging clues