TL;DR: Zustand is minimal global state for React. Define a store with
create(), use it anywhere with a hook - no Provider, no boilerplate. Subscribe to only the slices you need to avoid unnecessary re-renders. ~1kB. Official docs: zustand-demo.pmnd.rs.
What is Zustand?
Zustand is a minimal global state manager for React. You define a store - a plain object with state and actions - and subscribe to it in any component with a hook. No Provider required, no boilerplate, no reducers.
At ~1kB gzipped, it's hard to justify anything heavier for client state management.
I moved a mid-size dashboard off Redux Toolkit to Zustand a while back, mostly out of curiosity about how much boilerplate would actually disappear. The store file went from 340 lines of actions, reducers, and selectors to about 60 lines of plain functions. Nobody on the team missed the action-type constants.
When to use it
Use Zustand for client-side global state that multiple components need to share:
- Authentication state (user, token)
- UI state (sidebar open, theme, modal queues)
- Multi-step wizard state shared across routes
- Shopping cart, notification queue
Don't use it for server state (API responses) - that's TanStack Query's job. Don't use it for local component state - that's useState.
Zustand vs Jotai: Zustand is store-first (one object per domain); Jotai is atom-first (one hook per value). Zustand is easier to reason about for most teams.
Key Features
- No Provider - stores work outside React tree, usable in vanilla JS too
- Immer integration -
immermiddleware for mutation-style updates - Redux DevTools - inspect state history in browser devtools
- Persist middleware - sync state to
localStorageorsessionStorage - Subscriptions - subscribe to store changes outside React
Installation
npm install zustand
Basic Usage
import { create } from 'zustand'
interface CartStore {
items: CartItem[]
addItem: (item: CartItem) => void
removeItem: (id: string) => void
clear: () => void
}
const useCartStore = create<CartStore>((set) => ({
items: [],
addItem: (item) => set((state) => ({ items: [...state.items, item] })),
removeItem: (id) => set((state) => ({ items: state.items.filter(i => i.id !== id) })),
clear: () => set({ items: [] }),
}))
// In any component:
function CartCount() {
const count = useCartStore((state) => state.items.length)
return <span>{count}</span>
}
Using Immer for Complex Updates
import { create } from 'zustand'
import { immer } from 'zustand/middleware/immer'
const useStore = create<State>()(immer((set) => ({
users: [],
updateUser: (id, patch) => set((state) => {
const user = state.users.find(u => u.id === id)
if (user) Object.assign(user, patch) // Immer lets you mutate directly
}),
})))
Persist State to localStorage
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
const useSettingsStore = create<Settings>()(
persist(
(set) => ({
theme: 'light',
setTheme: (theme) => set({ theme }),
}),
{ name: 'app-settings' } // localStorage key
)
)
Subscribing Outside React
Sometimes you need to react to a state change without a component in the picture, an analytics call, a WebSocket reconnect, a toast triggered from a non-React module. subscribe handles that without turning the store into an event emitter you have to wire up yourself:
import { useCartStore } from './cart-store'
const unsubscribe = useCartStore.subscribe((state, prevState) => {
if (state.items.length > prevState.items.length) {
trackEvent('cart_item_added', { total: state.items.length })
}
})
// later, e.g. on unmount or route change
unsubscribe()
This runs outside the React render cycle entirely, so it's safe to call inside useEffect cleanup, a route change handler, or a plain script that never touches JSX.
TypeScript Tips
Always type your store interface separately - it enables reuse:
interface AuthStore {
user: User | null
token: string | null
login: (user: User, token: string) => void
logout: () => void
}
const useAuthStore = create<AuthStore>()((set) => ({
user: null,
token: null,
login: (user, token) => set({ user, token }),
logout: () => set({ user: null, token: null }),
}))
Always select only what you need - subscribing to the whole store re-renders on every change:
// ✅ Re-renders only when user.name changes
const name = useAuthStore((s) => s.user?.name)
// ❌ Re-renders on every store mutation
const store = useAuthStore()
Common Gotchas
- Object slices - if you select an object (
s => s.filters), Zustand uses reference equality. Addingshallowfromzustand/shallowprevents unnecessary re-renders when the object contents change but not the reference - Async actions - you can call
setfrom inside async functions directly; no special middleware needed - Testing - reset store state between tests with
useStore.setState(initialState)inbeforeEach
Related
- Jotai - atom-based alternative to Zustand; better for fine-grained subscriptions, Zustand is better for domain stores
- TanStack Query - use alongside Zustand: TanStack Query for server/async state, Zustand for client UI state
- TypeScript Generics Guide - type your Zustand stores with generic interfaces for full
create<StoreType>()safety