zustand
Zustand is a small state manager for React built around one idea: your store is a hook. You call create() with an object of state and actions, then any component subscribes to a slice of it with a selector. There are no providers wrapping the app, no reducers, and no action types unless you want them. The core is under 1 KB gzipped, works outside React through a vanilla store, and ships opt-in middleware for persistence, Immer, and Redux DevTools. The readme's claim that it handles the zombie-child and concurrency edge cases correctly has held up in practice.
The best default for client state in React apps: tiny, fast, and hard to misuse at small scale. Keep server data in a query library and keep stores disciplined, and it stays pleasant as the app grows.
Use it if
- You have outgrown useState plus context prop-drilling but Redux Toolkit feels like too much ceremony for the app's size
- You need components to re-render only when their selected slice changes, without wrapping the tree in providers
- You need to read or write state outside React (websocket handlers, game loops, analytics) via getState, setState, and subscribe
- You want extras like persist, devtools, and immer as composable middleware rather than a framework
- Your state is mostly server data; TanStack Query or SWR handles caching, refetching, and invalidation, which Zustand will not do for you
- You want enforced structure for a large team; Zustand is unopinionated, and without discipline stores turn into grab-bag globals where Redux Toolkit's conventions age better
- You lean on React Server Components in Next.js: a module-level store is shared across server requests, and the documented fix (per-request vanilla stores passed through context) erases much of the no-provider simplicity
- Your UI is heavy on derived state chains; Jotai's atom model fits fine-grained dependency graphs better than one flat store
Setup reality
npm install zustand and you are writing a store in five lines; there is genuinely no provider, config, or boilerplate step. The gotchas are conceptual instead: v5 removed default custom equality, so selectors returning fresh objects re-render on every change until you wrap them in useShallow; TypeScript needs the odd curried create<State>()(...) form; and persist plus SSR means hydration mismatches you handle with skipHydration or a mounted check. React 18 is the minimum peer dependency, and immer is a separate install if you use that middleware.
Patterns
Create a storecreate-store
import { create } from 'zustand'
const useBearStore = create((set) => ({
bears: 0,
increase: () => set((state) => ({ bears: state.bears + 1 })),
reset: () => set({ bears: 0 }),
}))set merges top-level keys by default; it does not deep-merge nested objects.
Subscribe to one valueselect-slice
function BearCounter() {
const bears = useBearStore((state) => state.bears)
return <h1>{bears} around here</h1>
}Calling useBearStore() with no selector subscribes to the whole store and re-renders on every change.
Select multiple values without extra rendersselect-multiple-shallow
import { useShallow } from 'zustand/react/shallow'
const { nuts, honey } = useBearStore(
useShallow((s) => ({ nuts: s.nuts, honey: s.honey })),
)Without useShallow the object literal is new every render, and since v5 that means a re-render every time.
Type a storetypescript-store
interface BearState {
bears: number
increase: (by: number) => void
}
const useBearStore = create<BearState>()((set) => ({
bears: 0,
increase: (by) => set((s) => ({ bears: s.bears + by })),
}))The extra () after create<BearState> is required; it is the currying workaround that keeps middleware inference working.
Async actionasync-action
const useFishStore = create((set) => ({
fishies: {},
fetch: async (pond) => {
const res = await fetch(pond)
set({ fishies: await res.json() })
},
}))No thunks or middleware needed; just call set when the promise resolves.
Persist to localStorage or sessionStoragepersist-storage
import { create } from 'zustand'
import { persist, createJSONStorage } from 'zustand/middleware'
const useFoodStore = create(
persist(
(set, get) => ({ fishes: 0, add: () => set({ fishes: get().fishes + 1 }) }),
{ name: 'food-storage', storage: createJSONStorage(() => sessionStorage) },
),
)Default storage is localStorage; with SSR frameworks guard against hydration mismatch via skipHydration or a mounted flag.
Wire up Redux DevToolsdevtools
import { devtools } from 'zustand/middleware'
const useStore = create(
devtools((set) => ({
fishes: 0,
eat: () => set((s) => ({ fishes: s.fishes - 1 }), undefined, 'fish/eat'),
})),
)The third argument to set names the action in DevTools; otherwise everything logs as anonymous.
Mutate nested state with Immerimmer-middleware
import { immer } from 'zustand/middleware/immer'
const useStore = create(
immer((set) => ({
nested: { deep: { count: 0 } },
bump: () => set((state) => { state.nested.deep.count += 1 }),
})),
)immer is a peer dependency you install separately.
Read and write state outside componentsread-outside-react
const paw = useDogStore.getState().paw
useDogStore.setState({ paw: false })
const unsub = useDogStore.subscribe(console.log)
unsub()getState is a one-time snapshot, not reactive; components reading it will not update on changes.
Subscribe to a specific valuesubscribe-with-selector
import { subscribeWithSelector } from 'zustand/middleware'
const useDogStore = create(
subscribeWithSelector(() => ({ paw: true, fur: true })),
)
useDogStore.subscribe(
(s) => s.paw,
(paw, prevPaw) => console.log(paw, prevPaw),
{ fireImmediately: true },
)Plain subscribe fires on every state change; this middleware adds the selector plus equality signature.
Split a big store into slicesslices-pattern
const createBearSlice = (set) => ({
bears: 0,
addBear: () => set((s) => ({ bears: s.bears + 1 })),
})
const createFishSlice = (set) => ({
fishes: 0,
addFish: () => set((s) => ({ fishes: s.fishes + 1 })),
})
const useBoundStore = create((...a) => ({
...createBearSlice(...a),
...createFishSlice(...a),
}))Slices share one flat namespace; a key collision between slices overwrites silently.
Per-request store for SSR/RSCssr-per-request-store
import { createContext, useContext } from 'react'
import { createStore, useStore } from 'zustand'
const StoreContext = createContext(null)
// create per tree/request, not at module scope
const makeStore = () => createStore((set) => ({ count: 0 }))
function Counter() {
const store = useContext(StoreContext)
const count = useStore(store, (s) => s.count)
return count
}Module-level stores leak state across requests on the server; the docs recommend vanilla stores passed through context for Next.js 13+.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| jotai | npm | Your state decomposes into many small derived atoms rather than one store |
| @reduxjs/toolkit | npm | A large team needs enforced conventions, entity adapters, and mature devtools workflows |
| valtio | npm | You prefer mutable proxy state you assign to directly instead of calling set |