When to reach for Zustand
A decision funnel for when a feature needs a global Zustand store, and when one of React's five state defaults already covers it.
You already hold client state five ways without a global store: a toggle in useState, related fields in useReducer, a value lifted through Context, a filter or sort in the URL, server data cached in TanStack Query.
Zustand is what people install when they think “global state,” but those five cover most of what that phrase means.
So the question is narrow: when does a feature need an in-memory store that lives across the whole app, outside all five? Later in this chapter you build a customer-onboarding wizard whose four steps sit on separate routes, each writing into one shared draft. That draft has no home among the five defaults.
One rule carries the lesson: if useState covers it, ship useState.
Zustand is conditional and per-feature, never the app’s general-purpose state bus.
The five defaults and the gap they leave
Section titled “The five defaults and the gap they leave”Each default below owns one shape of client state; the lesson’s triggers are defined by contrast with these rows.
| Default | What it owns |
|---|---|
useState | Transient local UI state one component owns — a toggle, an input value, an open/closed flag. |
useReducer | Related local state with coordinated transitions — three or more useState values that always update together. |
| Lifted state + React Context | Narrowly shared state read by consumers under one provider in one subtree, written rarely. |
nuqs URL state | Shareable view state — filters, sort, search, cursor pagination — that belongs in the address bar. |
| TanStack Query | Client-side server state, on its four triggers: polling, cross-view caching, optimistic-with-rollback, infinite scroll. |
One shape falls outside all five at once: state that is shared, is not the server’s, does not belong in the URL, and lives in memory across component trees that are disjoint or split across routes, with no common ancestor to hold it.
The three triggers that justify a store
Section titled “The three triggers that justify a store”Three reasons justify bringing Zustand into a codebase, and only three. Each crosses one of the defaults and carries a qualifier, the senior call that keeps it from being abused, and that qualifier is the hard part: the skill is recognizing when a feature isn’t one of these.
Genuinely shared state across disjoint or cross-route trees
Section titled “Genuinely shared state across disjoint or cross-route trees”This is the core trigger.
Lifting plus Context works on one condition: every consumer lives under one provider, in one subtree, and the value flows down to everyone below.
The moment your consumers stop sharing a natural ancestor, because they sit in disjoint trees or on the far sides of a route boundary, Context stops helping.
Take the wizard.
Step one renders under /customers/new/step-1, step four under step-4, and React tears down step one’s subtree the moment you navigate away.
To share the draft through Context you would hoist it to a layout wrapping all four routes, then thread it back down through every intermediate segment, making each one a Client Component that passes data it never reads, which is prop-drilling by another name.
The same shape shows up wherever the readers genuinely scatter:
- a command palette whose open state is read by the topbar, a layout overlay, and a global keyboard handler, three subtrees that never meet;
- a cart read by a header badge, a slide-over panel, and a checkout page on a different route.
The senior call: this trigger is real only when the consumers cannot sit under one natural provider. “Passing this prop down two levels feels tedious” is not the trigger; tedium is solved by lifting one more level. A store solves the case where there is no level left to lift to.
Imperative actions fired from unrelated subtrees
Section titled “Imperative actions fired from unrelated subtrees”Some state isn’t read across the tree, it’s commanded: open the global toast, fire the confirmation modal from this leaf, collapse the sidebar from a button buried six levels deep.
The default is to thread a callback down from wherever the toast or modal lives, which works for one or two callers.
A small store with an open() / close() / fire() action surface beats threading that callback through six layers of components that exist only to pass it along.
This is the most-abused trigger, so the gate matters: it is real only when more than two unrelated subtrees fire the action. If two callers share a parent, lift the callback there. You reach for a store when the callers scatter and no shared parent sits low enough to hold the callback without dragging it across half the tree.
Frequently mutated shared state where Context re-renders the whole tree
Section titled “Frequently mutated shared state where Context re-renders the whole tree”When any value on a Context changes, React re-renders every consumer of that Context, not just the ones that read the part that changed. For state that changes rarely, like a theme or a locale, that cost is invisible. It bites when state mutates constantly, like a cart recalculating per keystroke or a wizard validating per field, and many consumers each read a different slice: the whole-tree re-render becomes the bottleneck.
Zustand subscribes differently.
A component reads a store through a selector and re-renders only when its selected slice changes.
Read state.contact.email and you re-render when the email changes, not when the billing address three slices over does.
In the widget below, one parent holds three children that each read a different field; the tabs swap the strategy between Context and selectors. Click each trigger and watch the render badges.
Under Context, editing one field ticks every box; under selectors, the same edit ticks exactly one.
This is a profiled trigger: you reach for it when you have measured a re-render cost, not when you suspect one. Alone it is the weakest of the three, since a four-step wizard almost never has a render-cost problem worth a library. It earns its place stacked on triggers one and two, when state already shared across disjoint trees also mutates often.
When a default already owns it
Section titled “When a default already owns it”The triggers tell you when to reach for Zustand; knowing when not to is where most of the judgment lives. For each workload below, a default already owns it, and the move is to name that default out loud.
| Workload | Lives in — not Zustand |
|---|---|
| A single page's form state | useState, or React Hook Form once it grows multi-field or multi-step (Chapter 045). |
| Theme or locale the whole app reads | React Context — reads are rare, so the whole-tree re-render costs nothing. |
| Filter, sort, search, cursor pagination | nuqs plus Server Components — it belongs in the URL so it survives a refresh and a shared link. |
| Server data the client polls or optimistically updates | TanStack Query — it owns the cache, the refetch, and the rollback. |
| Auth session or current user | auth() in a Server Component, or the Better Auth client hook in a leaf. |
Zustand earns its weight only when every default is wrong.
Two overreaches account for almost every misuse of this library, and both skip that test.
The first reaches for a store because global state feels cleaner; six months later a new reader opens a 400-line useAppStore and has to trace which of twenty slices a bug touches.
That is the 2016 Redux store-of-the-universe rebuilt in fewer lines, with the same coupling cost.
The second uses Zustand to cache server data: you pull a list into a store, and the user sits on a stale view because nothing told it to refetch.
The cache, the refetch, and the staleness are TanStack Query’s job, so the real first question is whether the client owns this state at all.
Sort each piece of state into its correct home. Only one bucket is Zustand — and most of these are not it. Name the default first; reach for Zustand only when every default is wrong. Drag each item into the bucket it belongs to, then press Check.
The recurring cost of each store
Section titled “The recurring cost of each store”Every store you add carries the same recurring costs.
- A new “where does this state live?” question. The five defaults have known homes; a store is one more place every future reader has to check.
- A per-feature store file to find before changing anything.
- An SSR wiring trap. Defined the naive way, once at module scope, a store is shared across requests on the server, so one user’s draft can leak into the next user’s first render. The next lesson, Primitives and the provider, fixes it.
- A
'use client'boundary at every consumer. The store is client-only, so every component that reads it opts out of being a Server Component, a cost you pay at each call site rather than once. - Reset discipline. A store lives as long as the browser tab, so without an explicit reset on sign-out, org-switch, and successful submit, last session’s state bleeds into the next, a data-isolation bug. This is the
queryClient.clear()discipline from the TanStack Query chapter, now yours again.
A default carries none of these, so a store has to clear all five before it earns its place.
One store per feature, never one global store
Section titled “One store per feature, never one global store”The chapter’s central rule: every Zustand store has a single feature owner and lives next to the feature it owns.
There is no useAppStore holding everything.
A single useAppStore with twelve unrelated slices, cart, wizard, palette, theme, modal, and so on, is the Redux store-of-the-universe again: every feature’s state tangles in one module, every change is a merge-conflict magnet, every consumer imports the world to read one field.
Per-feature stores invert that: each surface stays small, you find it by the feature’s name, and you delete a feature by deleting its store.
- Cart →
- Wizard →
- Palette →
useAppStore- cart
- wizard
- palette
- theme
- modal
- toast
- sidebar
- notifications
- auth
- filters
- ← Theme
- ← Modal
- ← Toast
Every feature funnels into one module.
- Cart
useCartStore - Wizard
useWizardStore - Command palette
useCommandPaletteStore
Each store owns exactly one feature.
Two conventions follow:
- Name each store for its one owner:
useWizardStore,useCartStore,useCommandPaletteStore. The name tells a reader which feature it belongs to. - Co-locate the store beside that feature, never in a global
/storedirectory that becomes the new junk drawer.
A feature that lives on its own route keeps its store in that route’s private folders, beside the steps, schema, and submit action it serves:
Directorysrc/app/(app)/customers/new/
Directory_lib/wizard/
- store.ts the wizard store
useWizardStore - … types, slices
- store.ts the wizard store
Directory_components/
- wizard-store-provider.tsx
'use client'— wires the store in - …
- wizard-store-provider.tsx
- layout.tsx
- … the step pages this chapter builds toward
The exact files, and why they split across _lib/ and _components/, are the next lesson’s job.
What matters here is the shape: one store, named for its feature, sitting beside it.
The funnel: deciding before you install
Section titled “The funnel: deciding before you install”One question sits before the funnel, and it catches the most common Zustand misuse: using a store as a stand-in for a database. A store lives in browser memory, so a refresh wipes it. That sets the boundary precisely: Zustand owns client-only state that is allowed to vanish on refresh by product decision. If losing the state on refresh is unacceptable, it belongs on the server, not in a store.
From there, the triggers and costs collapse into one ordered set of gates: run them top to bottom and stop at the first that lands.
It’s the server’s. Read it in a Server Component and write it through a Server Action, or reach for TanStack Query if one of its four triggers applies: polling, cross-view cache, optimistic-with-rollback, or infinite scroll. The client should not own a copy.
One component owns it, nobody else reads it. This is the default. Don’t lift it until a sibling actually needs it.
Shared, but the consumers share a natural ancestor and you write to it rarely. Lift the state to that ancestor and pass it down through Context. The whole-tree re-render costs nothing at low write frequency.
Shareable view state belongs in the URL, where it survives a refresh and a shared link. Server Components read it straight from the params.
It’s client-only, genuinely shared across disjoint or cross-route trees, and no default fits. Reach for Zustand, and remember the sub-rule: one store per feature, never one global useAppStore, co-located beside the feature it owns.
Only state that falls through every gate lands on Zustand, which is why the stores stay few even in a large app.
Why Zustand over Redux, Jotai, or Valtio
Section titled “Why Zustand over Redux, Jotai, or Valtio”A teammate, an AI agent, or the top search result will offer you alternatives, so here is one paragraph each on why this course picks Zustand for greenfield 2026 work.
Redux Toolkit is the incumbent, heavier than the job needs: it still asks you to think in reducers, actions, and a dispatcher, ceremony Zustand’s v5 API drops. Reach for it only when a codebase already standardizes on it and consistency wins.
Jotai is atom-based: state is built bottom-up from many small independent atoms with a derivation graph between them. That shape fights the “a few named stores with slices” model this course teaches, and fits only when your state really is dozens of tiny atoms with computed relationships, which is rare in ordinary web UI.
Valtio lets you mutate state directly through a proxy, nicer in spots but with a smaller ecosystem. Zustand, Jotai, and Valtio are all pmndrs projects, and of the three only Zustand fits the canonical 2026 SaaS shape: a handful of named per-feature stores read through selector subscriptions.
The course’s call: Zustand v5, no detours.
The wizard this chapter builds
Section titled “The wizard this chapter builds”The destination is a four-step customer-onboarding wizard at /customers/new/step-1 through step-4: contact, billing, preferences, then a review screen.
Run it past the funnel and it lands on Zustand for two stacked reasons.
The four steps sit on disjoint route segments yet all write into one shared draft (trigger one), and the progress indicator, the Next button, and the review screen all read from that same draft across the tree (trigger two).
This lesson stays syntax-free: trigger before tool. Later lessons teach the primitives and the per-request provider, then wire them into this wizard.
External resources
Section titled “External resources”The maintainers’ own docs are the canonical reference, and their comparison page makes the Redux/Jotai/Valtio case from the inside. TkDodo, the TanStack Query maintainer you met earlier, writes the senior playbook on top: per-feature stores, selector discipline, and the Context-scoped store that previews the next lesson’s SSR fix.
The canonical reference for the v5 API the next lesson teaches.
pmndrs contrasts Zustand with Redux, Jotai, Valtio, and others from the maintainers' perspective.
TkDodo's senior playbook: custom-hook selectors, business logic in the store, and many small per-feature stores.
Per-instance stores scoped through a Context provider — the pattern behind the per-request fix for the SSR trap.
The official reference for the Context default the funnel weighs before it ever reaches a store.