Skip to content
Chapter 78Lesson 1

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.

Each default below owns one shape of client state; the lesson’s triggers are defined by contrast with these rows.

The five client-state defaults and the shape of state each one owns
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.

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.

Context re-renders everyone — selectors stay surgical

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.

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.

Workloads that look like global state and the default that owns each one instead of Zustand
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.

useState / Context A default React holds for you
nuqs / TanStack Query URL state or server state — still a default, not Zustand
Zustand Shared, client-only, may vanish on refresh
A modal’s open/closed flag owned by one component
An accordion’s currently expanded panel index
The app’s theme toggle, read app-wide
The invoices-list filter and sort
A comment thread the client polls every 10 seconds
The signed-in user’s current session
A four-step wizard’s draft shared across route segments
A command palette opened from three disjoint subtrees
One cart read by a header badge, a slide-over, and a checkout page

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.

Anti-pattern
  • Cart
  • Wizard
  • Palette
useAppStore
  • cart
  • wizard
  • palette
  • theme
  • modal
  • toast
  • sidebar
  • notifications
  • auth
  • filters
  • Theme
  • Modal
  • Toast

Every feature funnels into one module.

The rule
  • useCartStore
    Cart
  • useWizardStore
    Wizard
  • useCommandPaletteStore
    Command palette

Each store owns exactly one feature.

One store per feature keeps each surface small, named, and independently deletable; one store of everything recreates the coupling it avoids.

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 /store directory 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
    • Directory_components/
      • wizard-store-provider.tsx 'use client' — wires the store in
    • 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.

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.

Does this state need Zustand?

Only state that falls through every gate lands on Zustand, which is why the stores stay few even in a large app.

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 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.

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.