Skip to main content

Zustand State Management

Quick reference for managing global state with Zustand in your React application.

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?

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>
)
}
warning

The return of a function mutating the state (increment, decrement...) should be a object that will be merged with the current store, you can omit fields that should not be updated.

You should never mutate the existing state as it will lead to incorrect behavior (React compare object at the reference level only, it does not do a deep compare).

How do I manage complex data structures?

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
tasks: state.tasks,
filters: state.filters,
selectedTaskId: state.selectedTaskId
})
}
),
{ name: 'task-store' }
)
)

How do I integrate with API calls?

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 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
version: 2,
migrate: (persistedState: any, version: number) => {
if (version === 1) {
// Migrate from v1 to v2
return {
...persistedState,
userPreferences: {
...persistedState.userPreferences,
sidebarCollapsed: false // New field in v2
}
}
}
return persistedState
}
}
)
)

// 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()
})
})