Skip to content
Chapter 78Lesson 1

When Zustand earns its weight

The decision funnel that tells you when a feature genuinely needs the Zustand global client store, and when one of React's five state defaults already covers it.

By now you hold client state five different ways, and not once have you reached for a global store. A toggle lives in useState. Related fields that change together live in useReducer. A value a sibling needs gets lifted and passed through Context. A filter or a sort goes into the URL. Server data the client caches goes to TanStack Query.

Zustand is the library people install when they think “global state,” but those five defaults already cover most of what that phrase means. So this lesson answers a narrow question: when does a feature genuinely need an in-memory store that lives across the whole app, sitting outside every one of those five homes?

The destination is a real screen. Later in this chapter you build a four-step customer-onboarding wizard whose steps sit on separate routes, /customers/new/step-1 through step-4, and every step writes into one shared draft. That draft has nowhere good to live among the five defaults, and seeing exactly why is the point of the chapter. By the end of this lesson you can run a funnel that decides, before you install anything, whether Zustand earns its weight, and you can name the one architectural rule that keeps it from quietly spreading into everything.

One line carries the whole lesson, so hold onto it: if useState covers it, ship useState. Zustand is conditional, and it is per-feature. It is never the app’s general-purpose state bus.

The five defaults already cover most client state

Section titled “The five defaults already cover most client state”

Before we find the gap, anchor the map. Each of these owns a specific shape of state, and the triggers later are defined entirely by contrast with them, so this is recall, not new material.

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 — the threshold where three-plus useState values 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.

Each of these has a boundary, and one shape falls outside all five at once: state that is genuinely 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 natural common ancestor to hold it. That shape is what the rest of this lesson is about.

The three triggers that cross the threshold

Section titled “The three triggers that cross the threshold”

The course accepts three reasons to bring Zustand into a SaaS codebase, and only three. Each one names a default it crosses, and each one carries a qualifier, the senior call that keeps it from being abused. The qualifier is the part that takes judgment. Memorizing three triggers is easy; the skill is recognizing when a feature isn’t one of them.

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. The provider sits at a common ancestor, and the value flows down to everyone below it. 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. Think about the wizard. Step one renders under the route segment /customers/new/step-1, and step four renders under /customers/new/step-4. React tears down step one’s subtree the moment you navigate to step two. To share the draft through Context you would hoist it to some layout high enough to wrap all four routes, then thread it back down through every intermediate segment, and every one of those intermediates becomes a Client Component passing data it never reads. That is prop-drilling wearing a Context costume.

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 genuinely 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 really 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 move is to thread a callback down from wherever the toast or the modal actually lives. That 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 sit under a shared parent, lift the callback to that parent and pass it down. You reach for a store when the callers are scattered and there is no shared parent 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”

This trigger is about cost, and to feel it you need one fact about Context.

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. Everyone reading the Context re-renders, every time, on every update. For state that changes rarely, like a theme or a locale, that cost is invisible. The cost 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: that whole-tree re-render becomes the bottleneck.

Zustand subscribes differently. A component reads a store through a selector , a function that picks out just the slice it needs, and it re-renders only when that selected slice changes. Read state.contact.email and you re-render when the email changes, not when the billing address three slices over changes.

The asymmetry is easy to assert and hard to picture, so look at it directly. The following widget models one parent and three children, each child reading 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

In the Context tab, editing one field ticks every box. In the selector tab, the same edit ticks exactly one. That is the whole argument for trigger three.

Notice how narrow it is. This is a profiled trigger: you reach for it when you have measured a re-render cost, not when you suspect one. It is also the weakest of the three on its own, because a four-step wizard almost never has a render-cost problem worth a library. Trigger three rarely justifies Zustand by itself. It earns its place stacked on top of triggers one and two, when state that is already shared across disjoint trees also mutates often.

The triggers tell you when to reach for Zustand. This section tells you when not to, and that is where most of the judgment lives.

For each workload below, a default already owns it. The senior 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 a form crosses the multi-field / multi-step threshold (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.

The rule behind the table: name the default the workload would otherwise use, and only when every default is wrong does Zustand earn its weight. If useState covers it, ship useState.

Two overreaches account for almost every misuse of this library in real SaaS code, and both skip the question above.

The first is reaching for a store because “global state feels cleaner.” It doesn’t feel cleaner six months later, when a new reader opens a 400-line useAppStore and has to trace which of twenty slices a bug touches. This is the 2016 Redux store-of-the-universe rebuilt in fewer lines, and it carries the same coupling cost. The feeling that it is cleaner is the trap.

The second is using Zustand to cache server data: you pull a list into a store, and then a user sits on a stale view because nothing told the store to refetch. That is TanStack Query’s entire job, since it owns the cache, the refetch, and the staleness. Before any of the five defaults or Zustand, the real first question is whether the client owns this state at all, and we’ll formalize that check in a moment.

Try the following exercise first. You have both halves now, the three triggers and the defaults that cover everything else, so this is the exact skill the lesson teaches: take a concrete SaaS workload and drop it into its correct home.

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

“Earns its weight” only means something if the weight is real. Every store you add carries the same recurring costs, and naming them is what makes the high bar a deliberate choice rather than an arbitrary one.

  • A new “where does this state live?” question that every future reader has to ask and answer. The five defaults have known homes; a store is a new place to look.
  • A per-feature store file that readers have to find before they can change anything. (The file-layout rule in the next section exists to fix this.)
  • An SSR wiring trap. On the App Router, a store written the naive way, defined once at module scope, is shared across requests on the server, so one user’s draft can leak into the next user’s first render. The trap is real, and the next lesson fixes it. For now, just know it is there, because it is one of the reasons the bar is high.
  • 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. You pay that cost at each call site, not once.
  • Reset discipline. A store lives as long as the browser tab. Without an explicit reset on sign-out, on org-switch, and on a successful submit, last session’s state bleeds into the next one in the same tab. Forget it and you have a data-isolation bug at the client layer. This is the same discipline you met with queryClient.clear() at the tenancy boundary in the TanStack Query chapter, now your responsibility again.

A default carries none of these. That asymmetry is the reason Zustand is conditional: the library has to clear the combined cost of all five before it’s worth reaching for, which is why the three triggers that justify it are so narrow.

One store per feature, never one global store

Section titled “One store per feature, never one global store”

This is the chapter’s central architectural rule. State it plainly: every Zustand store has a single feature owner, and it lives next to the feature it owns. There is no useAppStore holding everything.

The rule is clearest seen through its failure mode. One useAppStore with twelve unrelated slices, cart, wizard, palette, theme, modal, toast, sidebar, and so on, is the Redux store-of-the-universe again, just shorter. Every feature’s state is tangled in one module, every change is a merge-conflict magnet, and every consumer imports the world to read one field. Per-feature stores invert all of that: each store’s surface stays small, you can find it by the feature’s name, and you can delete a feature by deleting its store and nothing else.

The following figure puts the two shapes side by side.

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 touches exactly one feature.

One store of everything recreates the coupling cost it was meant to avoid. One store per feature keeps each surface small, named, and independently deletable.

The convention that follows from the rule is mechanical:

  • Name each store for its one owner: useWizardStore, useCartStore, useCommandPaletteStore. The name tells a reader exactly which feature it belongs to.
  • Co-locate the store beside the feature that owns it. Its files live next to that feature’s other code, 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.

This rule is what keeps the chapter’s own tooling from becoming the thing the chapter warns against. It holds across every lesson here: one named store per feature, beside its feature, every time.

There is one question that sits before the whole funnel, and it catches the most common Zustand misuse in real code: using a store as a stand-in for a database.

Before you weigh useState against Context against a store, ask whether the state is actually the server’s. Plenty of state looks like client state and isn’t:

  • a draft that must survive a refresh is server state, so write a draft row and load it back;
  • a list view’s filter is URL state, so it belongs in the address bar;
  • a comment thread is server state, and TanStack Query owns it.

Here is the boundary that scopes Zustand precisely: Zustand owns client-only state that is allowed to vanish on refresh by product decision. A store lives in browser memory. Refresh the page and it’s gone, and that is by design, not a limitation. If losing the state on refresh is unacceptable, it does not belong in a store. It belongs on the server, with all the persistence and cleanup cost that implies, or in sessionStorage if the product can live with browser-tab durability. Either way, surviving a refresh is a product decision you make deliberately, not something you bolt onto a store because you forgot to ask where the state really lived.

So the first gate of the funnel is the bluntest one: is this the server’s state? If yes, you are done before you start.

Everything above collapses into a single ordered set of gates. The order is what matters: a senior asks these questions in this sequence, top to bottom, and stops at the first one that lands. Walk it once and commit each answer. The sequence is more durable than any flat cheat sheet, because it is the order you’ll actually run in your head the next time someone says “let’s add a store for that.”

Does this state need Zustand?

Only state that falls through every gate lands on Zustand. That is why, even in a large SaaS app, the number of stores stays small and each one is named for the feature it serves.

Why Zustand, and not Redux, Jotai, or Valtio

Section titled “Why Zustand, and not Redux, Jotai, or Valtio”

You will be offered alternatives, by a teammate, by an AI agent, or by the top of a search result, so it’s worth one paragraph each on why the course picks Zustand for greenfield 2026 work. This is “why this tool,” not a library survey.

Redux Toolkit is the incumbent, and it’s heavier than the job needs. It still asks you to think in reducers, actions, and a dispatcher, ceremony that Zustand’s v5 API simply doesn’t have. Pick Zustand by default in 2026, and reach for Redux Toolkit only when a codebase already standardizes on it and consistency wins.

Jotai is atom-based: state is built bottom-up out of many small independent atoms with a derivation graph between them. That is a genuinely different mental shape, and it fights the “a few named stores with slices” model this course teaches. Jotai is the right call when your state really is dozens of tiny independent atoms with computed relationships, which is rare in ordinary SaaS UI.

Valtio takes a proxy-based, mutate-it-directly approach: nicer ergonomics in spots, with a smaller ecosystem around it. Here’s the telling detail. Zustand, Jotai, and Valtio are all pmndrs projects, built by the same people, and of the three it’s Zustand that fits the canonical 2026 SaaS shape: a handful of named per-feature stores read through selector subscriptions.

The course’s call, stated once: Zustand v5, no detours.

The chapter has a destination, and it’s worth naming concretely so the rest of the lessons have something to point at.

It’s a four-step customer-onboarding wizard, living at /customers/new/step-1 through step-4: contact, then billing, then preferences, then a review screen. Run it past the funnel and it lands on Zustand, for stacked reasons rather than one. The four steps sit on disjoint route segments and all write into one shared draft: trigger one. The progress indicator, the Next button, and the review screen all read from that same draft across the tree: trigger two. No default fits, so the wizard clears the bar.

What it does not need from you yet is any Zustand code. This lesson is deliberately syntax-free: trigger before tool. The next lesson teaches the primitives a senior actually writes and the per-request provider that closes the SSR trap. The lesson after that runs the full funnel against this wizard and wires it up, and the project that follows builds the whole surface end to end. For now you have the thing that has to come first: the gate that decides whether the library belongs here at all.

When you’re ready to go deeper than the decision, the maintainers’ own docs are the canonical reference, and their comparison page makes the same Redux/Jotai/Valtio case from the inside. TkDodo, a maintainer you already met in the TanStack Query chapter, writes the senior playbook on top: per-feature stores, selector discipline, and the Context-scoped store that previews the next lesson’s SSR fix.