React Zustand Guide: Lightweight Global State (2026)
Zustand is a small, fast global state library for React. Unlike Redux, there's no boilerplate — no actions, reducers, or Provider wrapping. You define a store as a hook, access state anywhere and update it with simple functions. Despite its simplicity, Zustand scales well and supports middleware like persistence, devtools and Immer integration.
Table of Contents
Creating a Store
npm install zustand
import { create } from 'zustand'
// Define store with state + actions
const useCounterStore = create((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
decrement: () => set((state) => ({ count: state.count - 1 })),
reset: () => set({ count: 0 }),
incrementBy: (amount) => set((state) => ({ count: state.count + amount })),
}))
// Use anywhere — no Provider needed
function Counter() {
const { count, increment, decrement, reset } = useCounterStore()
return (
<div>
<p>Count: {count}</p>
<button onClick={increment}>+</button>
<button onClick={decrement}>-</button>
<button onClick={reset}>Reset</button>
</div>
)
}
Selectors and Performance
Components only re-render when their selected slice of state changes:
// ✅ Select only what you need — re-renders only when count changes
function CountDisplay() {
const count = useCounterStore((state) => state.count)
return <span>{count}</span>
}
// ✅ Select action references — actions are stable, no re-renders
function IncrementButton() {
const increment = useCounterStore((state) => state.increment)
return <button onClick={increment}>+</button>
}
// ❌ Destructuring without selector — re-renders on any state change
function BadComponent() {
const { count, increment } = useCounterStore() // Avoid this
}
For computed values derived from multiple state fields, use a selector with shallow comparison:
import { useShallow } from 'zustand/react/shallow'
// Re-renders only when firstName OR lastName changes
function UserName() {
const { firstName, lastName } = useUserStore(
useShallow((state) => ({ firstName: state.firstName, lastName: state.lastName }))
)
return <span>{firstName} {lastName}</span>
}
Async Actions
const useUserStore = create((set) => ({
user: null,
isLoading: false,
error: null,
fetchUser: async (userId) => {
set({ isLoading: true, error: null })
try {
const res = await fetch(`/api/users/${userId}`)
const user = await res.json()
set({ user, isLoading: false })
} catch (error) {
set({ error: error.message, isLoading: false })
}
},
updateUser: async (updates) => {
// Optimistic update
set((state) => ({ user: { ...state.user, ...updates } }))
try {
await fetch('/api/users', { method: 'PATCH', body: JSON.stringify(updates) })
} catch {
// Rollback on failure — refetch
useUserStore.getState().fetchUser(useUserStore.getState().user.id)
}
},
}))
TypeScript
import { create } from 'zustand'
interface BearState {
bears: number
honey: number
addBear: () => void
removeAllBears: () => void
collectHoney: (amount: number) => void
}
const useBearStore = create<BearState>()((set) => ({
bears: 0,
honey: 0,
addBear: () => set((state) => ({ bears: state.bears + 1 })),
removeAllBears: () => set({ bears: 0 }),
collectHoney: (amount) => set((state) => ({ honey: state.honey + amount })),
}))
// TypeScript infers types — fully typed selectors
const bears = useBearStore((state: BearState) => state.bears)
Middleware: devtools, persist, immer
import { create } from 'zustand'
import { devtools, persist } from 'zustand/middleware'
import { immer } from 'zustand/middleware/immer'
// Combine middleware (order matters — devtools outermost)
const useStore = create(
devtools(
persist(
immer((set) => ({
todos: [],
addTodo: (text) => set((state) => {
// Immer allows direct mutation
state.todos.push({ id: Date.now(), text, done: false })
}),
toggleTodo: (id) => set((state) => {
const todo = state.todos.find(t => t.id === id)
if (todo) todo.done = !todo.done
}),
removeTodo: (id) => set((state) => {
state.todos = state.todos.filter(t => t.id !== id)
}),
})),
{
name: 'todos-storage', // localStorage key
partialize: (state) => ({ todos: state.todos }), // Only persist todos
}
),
{ name: 'TodoStore' } // DevTools label
)
)
Custom persist storage (sessionStorage or custom):
persist(storeCreator, {
name: 'my-store',
storage: {
getItem: (key) => sessionStorage.getItem(key),
setItem: (key, value) => sessionStorage.setItem(key, value),
removeItem: (key) => sessionStorage.removeItem(key),
},
})
Slices Pattern
Split large stores into slices for better organization:
// slices/authSlice.ts
export const createAuthSlice = (set, get) => ({
user: null,
token: null,
login: async (credentials) => {
const { user, token } = await apiLogin(credentials)
set({ user, token })
},
logout: () => set({ user: null, token: null }),
})
// slices/cartSlice.ts
export const createCartSlice = (set, get) => ({
items: [],
addItem: (item) => set((state) => ({ items: [...state.items, item] })),
clearCart: () => set({ items: [] }),
get total() {
return get().items.reduce((sum, item) => sum + item.price * item.qty, 0)
},
})
// store/index.ts — combine all slices
import { create } from 'zustand'
import { createAuthSlice } from './slices/authSlice'
import { createCartSlice } from './slices/cartSlice'
export const useStore = create((...args) => ({
...createAuthSlice(...args),
...createCartSlice(...args),
}))
// Use specific slices
const user = useStore((s) => s.user)
const cartTotal = useStore((s) => s.total)
Accessing Store Outside React
const useUserStore = create((set, get) => ({
user: null,
setUser: (user) => set({ user }),
}))
// Read state outside a component
const currentUser = useUserStore.getState().user
// Update state outside a component (e.g., in an interceptor)
useUserStore.getState().setUser(null) // logout on 401
// Subscribe to changes outside React
const unsub = useUserStore.subscribe(
(state) => state.user,
(user, prevUser) => {
if (!user && prevUser) {
router.push('/login')
}
}
)
Testing
// Reset store between tests
import { act } from '@testing-library/react'
beforeEach(() => {
useCounterStore.setState({ count: 0 })
})
test('increment increases count', () => {
const { increment } = useCounterStore.getState()
act(() => increment())
expect(useCounterStore.getState().count).toBe(1)
})
// Mock store in component tests
test('renders correct count', () => {
useCounterStore.setState({ count: 42 })
const { getByText } = render(<Counter />)
expect(getByText('Count: 42')).toBeInTheDocument()
})