Zustand Patterns
Quick Guide: Zustand owns state two or more components read;
useStateowns state one component reads; the URL owns filters and search; Context injects singletons and holds no state. Zustand v5 changes three things:useShallowfromzustand/react/shallowreplaces the old equality-function second argument, selectors must return stable references or they loop, andpersistno longer captures initial state at creation.
Detailed Resources:
- examples/core.md — store setup, atomic selectors,
useShallow, the Context anti-pattern, URL state - reference.md — middleware lookup, v5 migration notes, peer-dependency requirements
Before writing Zustand code
Move a value into a store as soon as a second component reads it. Passing it down instead couples every component on the path, and relocating it later means unpicking that chain.
Select one value per useStore call. The component then re-renders when that value changes and at no other time; a call with no selector subscribes to the whole store.
Return a stable reference from every selector. An object or function built inside the selector is a new reference on each call, which in v5 re-renders without end.
Persist preferences and nothing else, through partialize. Transient UI restored from storage — a modal that reopens itself, a sidebar that remembers being closed — reads as a bug.
Keep server data out of the store. Cached, invalidated, refetched data wants a layer built for it; a store gives you a copy that goes stale silently.
Auto-detection: create from zustand, zustand/middleware, zustand/react/shallow, useShallow, createWithEqualityFn, zustand/traditional, persist, partialize, devtools, store slices
Applies to:
- Choosing between a store,
useState, the URL and Context for a given value - Setting up a store with
devtoolsandpersist - Selector shape and re-render behaviour
- Splitting state across focused stores
Handled elsewhere:
- Server data — caching, invalidation and refetching belong to whatever owns the network, and a store is not that thing
- Routing mechanics — this skill says which state belongs in the URL, not how a given router reads or writes it
- Form field state — a form library owns its own field values while the form is open
Zustand is a subscription primitive with a hook attached. Its performance comes entirely from the selector: the store notifies every subscriber on every change, and the selector is what decides whether that notification becomes a render. So store design is selector design.
- Small stores over one large one — a store is the unit of subscription as well as the unit of code
- Business logic in actions — components call
toggleSidebar(); the store decides what toggling means - Export hooks, not the creator — the raw store is a mutable global, and exporting it invites writes from anywhere
- Atomic selectors first — a single value needs no comparison function at all
Where a value belongs
| The value | Belongs in | Because |
|---|---|---|
| Read by 2+ components | a store | selective re-renders, and no prop chain to unpick later |
| Read by 1 component | useState |
nothing else can observe it, so nothing else needs to |
| A filter, query, page or sort | the URL | shareable, bookmarkable, and the back button works |
| A singleton set at startup | Context | it never changes, so the re-render cost never arises |
| Fetched from an API | not this skill | it needs caching and invalidation, which a store has none of |
Context is the row people get wrong. It is a transport for a value that does not change, and using it for state that does re-renders every consumer on every change with no way to opt out.
Core patterns
Pattern 1: Store Setup
devtools for inspection, persist for the fields that should outlive the tab, partialize to keep everything else out of storage.
export const useUIStore = create<UIState>()(
devtools(
persist(
(set) => ({
theme: DEFAULT_THEME,
sidebarOpen: true,
toggleSidebar: () =>
set((state) => ({ sidebarOpen: !state.sidebarOpen })),
}),
{ name: UI_STORAGE_KEY, partialize: (state) => ({ theme: state.theme }) },
),
),
);
Full code: examples/core.md
Pattern 2: Atomic Selectors
One value per call is the default, and the reason the store is fast.
const toggleSidebar = useUIStore((state) => state.toggleSidebar);
const isOpen = useUIStore((state) => state.sidebarOpen);
Full code: examples/core.md
Pattern 3: useShallow for Multiple Values
At three or more values from one store, one useShallow call beats three subscriptions.
import { useShallow } from "zustand/react/shallow";
const { sidebarOpen, theme } = useUIStore(
useShallow((state) => ({
sidebarOpen: state.sidebarOpen,
theme: state.theme,
})),
);
Full code: examples/core.md
Pattern 4: Context for Injection, Not State
Context carries a value that never changes — a client, a connection, a configuration read at startup.
const DatabaseContext = createContext<Database | null>(null);
Full code: examples/core.md
Pattern 5: URL State for Shareable Filters
Filters, search, pagination and sort live in the URL, where they survive a reload and a paste into a colleague's chat.
const searchParams = new URLSearchParams(window.location.search);
const category = searchParams.get("category") ?? undefined;
Full code: examples/core.md
Red flags
Breaks at runtime:
- A selector returning an inline object or function — new reference every call, so v5 re-renders without end. Hoist the fallback to a module constant.
create()called with a second equality-function argument — removed in v5, so the argument is ignored and the comparison silently never happens. UseuseShallow, orcreateWithEqualityFnfromzustand/traditional.- Reading a computed initial value back out of a persisted store immediately after creation — v5's
persistdid not store it. Set it withsetStateafter creation.
Surprising behaviour:
useStore()with no selector subscribes to the entire store, so the component re-renders on changes to fields it never reads.- Two atomic selectors returning equal values still render separately if either changes;
useShallowcompares the object, not the individual reads. - Context re-renders every consumer whenever the provider's value object is rebuilt, which is every render unless the value is memoised — and even memoised, a change to one field re-renders readers of all of them.
- Search params are strings.
?page=2reads back as"2", and?open=falseis truthy until parsed. - One large store means one large subscription surface: every component reading from it competes with every write to it.