React Zustand Guide: Lightweight Global State
Zustand is a minimal, fast global state library for React. At ~1KB gzipped, it avoids Redux boilerplate while providing a clean API that works outside components, supports middleware, and integrates with React DevTools. You define a store as a single function — state and actions together — and access exactly the slice you need in any component via selector hooks, avoiding unnecessary re-renders.
Table of Contents
Creating a Store
A Zustand store is created with create. You pass a function that receives set and get and returns an object containing both state and actions. The returned hook is used directly in components — no Provider wrapper needed.
import { create } from 'zustand'
// Counter store — state and actions in one object
const useCounterStore = create((set) => ({
count: 0,
step: 1,
increment: () => set((state) => ({ count: state.count + state.step })),
decrement: () => set((state) => ({ count: state.count - state.step })),
setStep: (step) => set({ step }),
reset: () => set({ count: 0, step: 1 }),
}))
// Usage — subscribe to only the slice you need
function Counter() {
const count = useCounterStore((state) => state.count)
const increment = useCounterStore((state) => state.increment)
const decrement = useCounterStore((state) => state.decrement)
return (
<div>
<button onClick={decrement}>-</button>
<span>{count}</span>
<button onClick={increment}>+</button>
</div>
)
}
// Cart store — more complex state
const useCartStore = create((set, get) => ({
items: [],
isOpen: false,
addItem: (product) => set((state) => {
const existing = state.items.find(i => i.id === product.id)
if (existing) {
return {
items: state.items.map(i =>
i.id === product.id ? { ...i, qty: i.qty + 1 } : i
),
}
}
return { items: [...state.items, { ...product, qty: 1 }] }
}),
removeItem: (id) => set((state) => ({
items: state.items.filter(i => i.id !== id),
})),
clearCart: () => set({ items: [] }),
toggleCart: () => set((state) => ({ isOpen: !state.isOpen })),
// Derived value via get()
getTotal: () => get().items.reduce((sum, i) => sum + i.price * i.qty, 0),
getItemCount: () => get().items.reduce((sum, i) => sum + i.qty, 0),
}))
Selectors and Performance
Zustand re-renders a component only when the value returned by its selector changes. Always select the minimum state slice needed. Selecting the entire state object causes re-renders on every state change — the same performance anti-pattern as connecting to the whole Redux store.
// Good — select only what you need
const count = useCounterStore((state) => state.count)
const items = useCartStore((state) => state.items)
const addItem = useCartStore((state) => state.addItem) // stable function ref
// Bad — subscribes to the entire store
const store = useCartStore() // re-renders on ANY state change
// Computed / derived selectors
const itemCount = useCartStore((state) =>
state.items.reduce((sum, i) => sum + i.qty, 0)
)
// Equality function for object selectors
import { useShallow } from 'zustand/react/shallow'
// Without useShallow — new object on every render causes infinite re-renders
const { count, step } = useCounterStore((state) => ({ count: state.count, step: state.step })) // BAD
// With useShallow — only re-renders when count or step actually changes
const { count, step } = useCounterStore(
useShallow((state) => ({ count: state.count, step: state.step }))
)
// Extracting multiple actions — stable references, no shallow needed
const increment = useCounterStore((state) => state.increment)
const decrement = useCounterStore((state) => state.decrement)
// Memoized selector with parameters
const selectItemById = (id) => (state) => state.items.find(i => i.id === id)
function CartItem({ id }) {
const item = useCartStore(selectItemById(id))
return <div>{item?.name} × {item?.qty}</div>
}
Async Actions
Zustand actions are plain functions — they can be async without any middleware. Call set inside the async action to update state at any point during the async operation. This is far simpler than Redux Thunk or Redux Saga for the same pattern.
const useUserStore = create((set, get) => ({
users: [],
selectedUser: null,
loading: false,
error: null,
fetchUsers: async () => {
set({ loading: true, error: null })
try {
const res = await fetch('/api/users')
if (!res.ok) throw new Error('Failed to fetch users')
const users = await res.json()
set({ users, loading: false })
} catch (err) {
set({ error: err.message, loading: false })
}
},
fetchUser: async (id) => {
set({ loading: true })
try {
const res = await fetch(`/api/users/${id}`)
const user = await res.json()
set({ selectedUser: user, loading: false })
} catch (err) {
set({ error: err.message, loading: false })
}
},
updateUser: async (id, data) => {
// Optimistic update
const previous = get().users
set((state) => ({
users: state.users.map(u => u.id === id ? { ...u, ...data } : u),
}))
try {
await fetch(`/api/users/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
} catch (err) {
// Rollback
set({ users: previous, error: err.message })
}
},
deleteUser: async (id) => {
await fetch(`/api/users/${id}`, { method: 'DELETE' })
set((state) => ({ users: state.users.filter(u => u.id !== id) }))
},
}))
// Component usage
function UserList() {
const { users, loading, error, fetchUsers } = useUserStore(
useShallow((s) => ({ users: s.users, loading: s.loading, error: s.error, fetchUsers: s.fetchUsers }))
)
useEffect(() => { fetchUsers() }, [fetchUsers])
if (loading) return <Spinner />
if (error) return <p>Error: {error}</p>
return <ul>{users.map(u => <li key={u.id}>{u.name}</li>)}</ul>
}
Slice Pattern for Large Stores
As your application grows, a single massive store becomes unwieldy. The slice pattern splits the store into domain-specific slices that are combined into one root store. Each slice is an independent function that receives set, get, and the store — making slices portable and testable in isolation.
// slices/authSlice.js
export const createAuthSlice = (set, get) => ({
user: null,
isAuthenticated: false,
login: async (credentials) => {
const user = await authApi.login(credentials)
set({ user, isAuthenticated: true })
},
logout: () => {
authApi.logout()
set({ user: null, isAuthenticated: false })
// Reset other slices too
get().clearCart?.()
},
})
// slices/cartSlice.js
export const createCartSlice = (set) => ({
items: [],
addItem: (item) => set((state) => ({ items: [...state.items, item] })),
removeItem: (id) => set((state) => ({ items: state.items.filter(i => i.id !== id) })),
clearCart: () => set({ items: [] }),
})
// slices/uiSlice.js
export const createUiSlice = (set) => ({
theme: 'dark',
sidebarOpen: false,
setTheme: (theme) => set({ theme }),
toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
})
// Root store — combine all slices
import { create } from 'zustand'
import { devtools, persist } from 'zustand/middleware'
export const useStore = create(
devtools(
persist(
(...args) => ({
...createAuthSlice(...args),
...createCartSlice(...args),
...createUiSlice(...args),
}),
{ name: 'app-store', partialize: (state) => ({ user: state.user, theme: state.theme }) }
)
)
)
Middleware: devtools, immer, persist
Zustand's middleware system wraps the create call. Stack multiple middlewares by nesting them. The three most useful are devtools (Redux DevTools integration), persist (localStorage/sessionStorage sync), and immer (mutable draft state syntax for complex nested updates).
import { create } from 'zustand'
import { devtools, persist, createJSONStorage } from 'zustand/middleware'
import { immer } from 'zustand/middleware/immer'
// All three middleware stacked
const useStore = create(
devtools(
persist(
immer((set) => ({
todos: [],
addTodo: (text) => set((state) => {
// Immer: mutate draft directly — no spread needed
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',
storage: createJSONStorage(() => localStorage),
partialize: (state) => ({ todos: state.todos }), // only persist todos
version: 1,
migrate: (persisted, version) => {
if (version === 0) return { ...persisted, todos: [] }
return persisted
},
}
),
{ name: 'TodoStore' } // name shown in Redux DevTools
)
)
Accessing Store Outside React
One of Zustand's biggest advantages over Context is that you can read and write the store from anywhere — event handlers, utility functions, WebSocket listeners, service workers — without hooks.
// Access store outside React components
import { useStore } from './store'
// Read current state
const state = useStore.getState()
console.log(state.user)
// Update state
useStore.setState({ theme: 'light' })
useStore.setState((state) => ({ count: state.count + 1 }))
// Subscribe to changes (returns unsubscribe function)
const unsubscribe = useStore.subscribe(
(state) => state.user, // selector
(user, previousUser) => { // listener
if (user && !previousUser) {
console.log('User logged in:', user.name)
analytics.track('login', { userId: user.id })
}
}
)
// WebSocket handler updating store without React
socket.on('notification', (data) => {
useStore.setState((state) => ({
notifications: [data, ...state.notifications],
}))
})
// In an axios interceptor
axios.interceptors.response.use(
null,
(error) => {
if (error.response?.status === 401) {
useStore.getState().logout()
}
return Promise.reject(error)
}
)
TypeScript Patterns
Zustand has excellent TypeScript support. Define an interface for your store shape and pass it as a generic to create. The StateCreator type enables fully typed slice functions with cross-slice access.
import { create, StateCreator } from 'zustand'
interface CounterState {
count: number
step: number
increment: () => void
decrement: () => void
setStep: (step: number) => void
reset: () => void
}
const useCounterStore = create<CounterState>()((set) => ({
count: 0,
step: 1,
increment: () => set((s) => ({ count: s.count + s.step })),
decrement: () => set((s) => ({ count: s.count - s.step })),
setStep: (step) => set({ step }),
reset: () => set({ count: 0, step: 1 }),
}))
// Typed slice pattern
interface AuthSlice {
user: User | null
login: (creds: Credentials) => Promise<void>
logout: () => void
}
interface CartSlice {
items: CartItem[]
addItem: (item: Product) => void
clearCart: () => void
}
type AppStore = AuthSlice & CartSlice
type SliceCreator<T> = StateCreator<AppStore, [], [], T>
const createAuthSlice: SliceCreator<AuthSlice> = (set) => ({
user: null,
login: async (creds) => {
const user = await authApi.login(creds)
set({ user })
},
logout: () => set({ user: null }),
})
const useAppStore = create<AppStore>()((...args) => ({
...createAuthSlice(...args),
...createCartSlice(...args),
}))
Zustand vs Context vs Redux
Context + useReducer is fine for low-frequency updates (theme, auth) but causes performance problems with high-frequency state because every Context consumer re-renders. Zustand solves this with selector-based subscriptions and no Provider overhead. Redux remains the right choice for very large teams that need strict conventions, time-travel debugging, and ecosystem tooling. Zustand is the sweet spot for most production applications: minimal setup, excellent performance, and full DevTools support.
// Decision guide
//
// useState / useReducer — local component state, no sharing needed
// Context + useReducer — low-frequency global state (theme, auth session)
// Zustand — shared UI state, shopping carts, multi-step forms
// TanStack Query — server/async data (NOT a Zustand use case)
// Redux Toolkit — large teams, complex state, strict conventions needed
//
// Zustand store size guide:
// Small app — 1 store with slices
// Medium app — 1 store with 3–5 slices
// Large app — domain stores (useAuthStore, useCartStore, useUIStore) separately
//
// Reset store on logout
const resetStores = () => {
useAuthStore.setState(authInitialState)
useCartStore.setState(cartInitialState)
useUIStore.setState(uiInitialState)
}
// Testing Zustand stores
describe('cartStore', () => {
beforeEach(() => {
useCartStore.setState({ items: [] }) // reset between tests
})
it('adds item to cart', () => {
useCartStore.getState().addItem({ id: 1, name: 'Book', price: 9.99 })
expect(useCartStore.getState().items).toHaveLength(1)
})
})