Primitives and the per-request provider
The Zustand v5 store API and the App Router wiring that gives each server request its own store, so one tenant's state never leaks into another's.
The previous lesson settled when a feature earns a Zustand store: genuinely shared client state across disjoint or cross-route trees, with selectors keeping re-renders narrow. This lesson is the how. You will see the v5 API an experienced engineer actually writes, and the one App Router wiring step that, if you skip it, turns the library into a multi-tenant data leak. Once a feature has cleared the threshold, two questions remain: what is the minimal API surface, and how do you wire the store into the App Router so one request’s state never bleeds into the next? The pieces are few: the store as a primitive, the create / createStore split, the three-file per-request provider, slices, and selectors. One of them, the per-request rule, is the only one that decides correctness rather than ergonomics.
Install
Section titled “Install”Zustand ships from a single package. The 5.x line is the May 2026 baseline, and everything below assumes it.
pnpm add zustandThe store is a subscription primitive
Section titled “The store is a subscription primitive”Before any of the App Router wiring, look at the store on its own, because it is smaller than its reputation. A Zustand store holds one state object and exposes exactly three functions: getState() reads it, setState() writes it, and subscribe() registers a callback that fires on every write. There are no reducers, no action types, no dispatcher. The state and the actions that mutate it live together in one closure. React reads the store through a useStore-style hook that re-renders the calling component only when the slice it selected changes. That is the entire model. Everything else in this lesson is typing and wiring on top of those three functions.
The smallest possible store makes that concrete. Here is a counter written with create, the React-bound form. It hands you a ready-to-use hook, so you can see the primitive without a provider in the way:
import { create } from 'zustand';
const useCounterStore = create<{ count: number; increment: () => void; reset: () => void;}>()((set) => ({ count: 0, increment: () => set((state) => ({ count: state.count + 1 })), reset: () => set({ count: 0 }),}));create<State>()(...) takes a creator function and returns a hook. The double call, create<...>()(...), is the v5 signature that lets you name the state type while TypeScript still infers the creator. The creator receives set and returns the initial state object.
import { create } from 'zustand';
const useCounterStore = create<{ count: number; increment: () => void; reset: () => void;}>()((set) => ({ count: 0, increment: () => set((state) => ({ count: state.count + 1 })), reset: () => set({ count: 0 }),}));An action is just a function on the state object. set((state) => ({ count: state.count + 1 })) is the functional update: you receive the current state and return the change. Zustand merges what you return into the existing state, so you only return the keys that changed.
import { create } from 'zustand';
const useCounterStore = create<{ count: number; increment: () => void; reset: () => void;}>()((set) => ({ count: 0, increment: () => set((state) => ({ count: state.count + 1 })), reset: () => set({ count: 0 }),}));set({ count: 0 }) is the absolute write: pass a plain partial and Zustand shallow-merges it on top. Use the functional form when the next value depends on the current one, and the absolute form when it does not.
import { create } from 'zustand';
const useCounterStore = create<{ count: number; increment: () => void; reset: () => void;}>()((set) => ({ count: 0, increment: () => set((state) => ({ count: state.count + 1 })), reset: () => set({ count: 0 }),}));A component reads with useCounterStore((s) => s.count). That callback is the selector, and it subscribes the component to count alone. We will return to why that matters in its own section.
The set function is the whole write API, and it has three shapes worth knowing precisely. set((state) => ({ count: state.count + 1 })) is the functional update, which derives the next value from the current one. set({ count: 5 }) is the absolute write: Zustand shallow-merges the partial into the existing state, so untouched top-level keys survive. And set(partial, true) is the rare replace flag, which wipes the entire state object instead of merging. You almost never want it. The one legitimate use is resetting a store back to its initial state, which we will reach later. In v5 the type-checker guards it: set({}, true) with an incomplete object is a compile error, because replace: true requires a complete state. The rule replace enforces is a full initial state, never a partial.
The other half is get , the read from inside the store. An action reaches for it when it needs to look at current values before deciding what to write:
addItem: (item) => { if (get().items.some((i) => i.id === item.id)) return; set((state) => ({ items: [...state.items, item] }));},That is the canonical action shape: each action is a method on the state object, sitting next to the values it touches, calling get to read and set to write. There is no separate reducer file to keep in sync.
Notice what set and get are not: they are not React hooks. They are plain functions on a plain object, which is exactly why a Zustand store works entirely outside React. It is also why, in a moment, the same store will turn out to be shared across server requests if you let it.
One more point before we move on. The counter above uses create, which is correct for a React app that never runs on the server. The course’s stack does run on the server, and that single fact forces a different form. The next section explains why.
create vs createStore: why SSR forces a provider
Section titled “create vs createStore: why SSR forces a provider”This is the one part of the wiring that decides correctness, so we will start with the failure before the fix.
Start with what create actually returns. create<State>(...) gives you a React hook backed by a single store instance, and that instance lives at module scope : it is created once, the first time the module is imported, and reused forever after. On a client-only app that is fine, because there is one browser, one user, one store. On the server it is a trap. Your Next.js process imports each module once and serves every request from that same loaded module. So a module-scoped wizard store is one store shared by every request the process handles at the same time.
Picture the consequence on a multi-tenant SaaS. Tenant Acme starts the customer wizard, and their request writes email: ada@acme.com into the module-scoped store. A moment later tenant Globex hits the same route. Their request renders on the server against that same store instance, so it reads Acme’s draft. Name, email, billing address: one tenant’s in-progress data rendered into another tenant’s response. This is not a slow render or a stale cache. It is a data-isolation bug, the most serious class of failure a multi-tenant app can ship.
You have met this exact shape before. The module-scoped QueryClient in the TanStack Query lesson leaked one request’s cached data into another request’s render for precisely this reason: server module scope is shared across every request. Recognize it rather than meeting it new. The diagnosis is identical, and only the fix differs.
email: ada@acme.com wizard storeThe fix is the v5 split. The library ships two entry points, and choosing between them is the decision:
create, fromzustand, gives you a ready-to-use React hook bound to one module-scoped store. It is correct for a non-SSR single-page app, where one user per process makes module scope harmless.createStore, fromzustand/vanilla, gives you a plain vanilla store withgetState/setState/subscribeand no React binding at all. You wrap it yourself in React Context and read it with the genericuseStorehook fromzustand.
The App Router rule, stated once and never bent for the rest of this chapter, is createStore plus a per-request provider. The provider creates a fresh store for each request, so there is no shared instance for SSR to leak across responses. Here are the two forms side by side, the one that leaks and the one to write:
// module top — runs once per processexport const useWizardStore = create<WizardState>()((set) => ({ /* ...state and actions... */}));One store for the whole server process. Tenant A’s draft is sitting in tenant B’s request, because the module-scoped instance is shared across every render the process serves.
<WizardStoreProvider> {/* createStore() runs once per request, pinned for the session */} {children}</WizardStoreProvider>A fresh store per request. The provider pins a new store with a ref, so SSR has nothing to share, and once in the browser that same store survives the session.
This is the same Leaks / Per-request pairing you saw for the QueryClient in the TanStack Query chapter: the same problem and the same shape of fix, with a provider in place of an isServer branch.
The three-file per-request provider
Section titled “The three-file per-request provider”The per-request store is three small files, each with one job. Naming them up front lets the rest of the section read as filling in a shape you already hold:
- The factory,
createWizardStore(initialState), built oncreateStore. It is pure, uses no React, and returns a vanilla store. - The provider, a
'use client'component that creates the store once withuseRefand hands it down through Context. - The typed hook,
useWizardStore(selector), which reads this request’s store from Context and binds it to React withuseStore.
Because the wizard is one feature on one route subtree, the whole store lives next to that route, not in lib/. That placement is forced: the project’s conventions forbid React imports inside lib/, and the provider and hook both use React. So the pure, type-only files sit in the route’s private _lib/ folder, and the two React files sit in its _components/ folder. We will see the full map at the end; for now, follow the three files.
File 1: the factory
Section titled “File 1: the factory”The factory is a function that returns a fresh vanilla store. We will keep its body a placeholder here and compose the real slices in the next section. What matters now is the signature, and that it returns a createStore result rather than calling createStore at module scope.
import { createStore } from 'zustand/vanilla';import type { WizardStore } from './wizard-types';
export const createWizardStore = () => createStore<WizardStore>()((set) => ({ /* state + actions, composed from slices in the next section */ }));This file has no runtime React, since createStore is vanilla and WizardStore is a type-only import, so it is allowed to sit beside the route even though it carries no 'use client'. It is a factory, not a singleton: nobody calls it at module scope. The provider calls it, once per request.
File 2: the provider
Section titled “File 2: the provider”The provider is where the per-request guarantee actually lives. Read it closely, because every line does real work.
'use client';import { createContext, useRef, type ReactNode } from 'react';import { createWizardStore, type WizardStore } from '../_lib/wizard/store';
export const WizardStoreContext = createContext<WizardStore | null>(null);
export function WizardStoreProvider({ children }: { children: ReactNode }) { const storeRef = useRef<WizardStore | null>(null); if (storeRef.current === null) { storeRef.current = createWizardStore(); } return ( <WizardStoreContext value={storeRef.current}> {children} </WizardStoreContext> );}Context and refs only exist in Client Components, so the provider has to be a client boundary. Forget this directive and the build fails with createContext is not a function, the single most common setup mistake with this pattern.
'use client';import { createContext, useRef, type ReactNode } from 'react';import { createWizardStore, type WizardStore } from '../_lib/wizard/store';
export const WizardStoreContext = createContext<WizardStore | null>(null);
export function WizardStoreProvider({ children }: { children: ReactNode }) { const storeRef = useRef<WizardStore | null>(null); if (storeRef.current === null) { storeRef.current = createWizardStore(); } return ( <WizardStoreContext value={storeRef.current}> {children} </WizardStoreContext> );}This Context is the channel that carries this request’s store down the tree. Its value type is WizardStore | null: it is null until the provider sets it, which is exactly what the hook’s guard will check for.
'use client';import { createContext, useRef, type ReactNode } from 'react';import { createWizardStore, type WizardStore } from '../_lib/wizard/store';
export const WizardStoreContext = createContext<WizardStore | null>(null);
export function WizardStoreProvider({ children }: { children: ReactNode }) { const storeRef = useRef<WizardStore | null>(null); if (storeRef.current === null) { storeRef.current = createWizardStore(); } return ( <WizardStoreContext value={storeRef.current}> {children} </WizardStoreContext> );}useRef creates the store exactly once per component instance: one instance per request on the server, one per session in the browser. React 19 requires the explicit null argument. The === null check, rather than !storeRef.current, is the form the official docs use, and it is safe under the React Compiler.
'use client';import { createContext, useRef, type ReactNode } from 'react';import { createWizardStore, type WizardStore } from '../_lib/wizard/store';
export const WizardStoreContext = createContext<WizardStore | null>(null);
export function WizardStoreProvider({ children }: { children: ReactNode }) { const storeRef = useRef<WizardStore | null>(null); if (storeRef.current === null) { storeRef.current = createWizardStore(); } return ( <WizardStoreContext value={storeRef.current}> {children} </WizardStoreContext> );}The provider wraps children and passes the pinned store as its value. In React 19 you render <Context value={...}> directly, with no .Provider needed.
The createContext call and the useRef pin are the two halves of the per-request guarantee, and the choice of useRef over the obvious alternatives is the whole point. A module-scoped const wizardStore = createStore(...) is the exact leak from the previous section: one store for every request. useRef instead creates the store once per component instance, and there is one component instance per request on the server and one per session in the browser. That makes the per-request boundary structural.
You may see the official guide write this with useState instead, as const [store] = useState(() => createWizardStore()). The lazy initializer also runs exactly once, so the two are equivalent: both forms create the store a single time. This course standardizes on useRef with the explicit === null guard, because it reads as what it is, a stored instance the render does not depend on, and it is the Compiler-safe shape the Zustand docs now show. Pick one and do not mix them in a codebase.
File 3: the typed hook
Section titled “File 3: the typed hook”The hook is the only thing components import. It hides the Context plumbing and gives every call site a typed, selector-driven read, built on Zustand’s generic useStore .
import { useContext } from 'react';import { useStore } from 'zustand';import { WizardStoreContext } from './wizard-store-provider';import type { WizardState } from '../_lib/wizard/wizard-types';
export function useWizardStore<T>(selector: (state: WizardState) => T): T { const store = useContext(WizardStoreContext); if (store === null) { throw new Error('useWizardStore must be used within a WizardStoreProvider'); } return useStore(store, selector);}Read the store from Context. This is this request’s instance, the one the provider pinned. The hook never reaches a module-scoped store, so the leak has no path back in.
import { useContext } from 'react';import { useStore } from 'zustand';import { WizardStoreContext } from './wizard-store-provider';import type { WizardState } from '../_lib/wizard/wizard-types';
export function useWizardStore<T>(selector: (state: WizardState) => T): T { const store = useContext(WizardStoreContext); if (store === null) { throw new Error('useWizardStore must be used within a WizardStoreProvider'); } return useStore(store, selector);}The guard throws a readable error if a component renders outside the provider. A clear message beats a downstream Cannot read properties of null from useStore three frames away, and the small touch saves a debugging session.
import { useContext } from 'react';import { useStore } from 'zustand';import { WizardStoreContext } from './wizard-store-provider';import type { WizardState } from '../_lib/wizard/wizard-types';
export function useWizardStore<T>(selector: (state: WizardState) => T): T { const store = useContext(WizardStoreContext); if (store === null) { throw new Error('useWizardStore must be used within a WizardStoreProvider'); } return useStore(store, selector);}useStore from zustand is the generic hook that binds a vanilla store to React. It runs selector against the store and subscribes the component to only the selected slice, re-rendering it when that slice changes and only then.
Where the provider mounts
Section titled “Where the provider mounts”Where you mount the provider matters as much as its code. It goes on the shared route-segment layout that wraps the four wizard steps, customers/new/layout.tsx, not on each step page.
import type { ReactNode } from 'react';import { WizardStoreProvider } from './_components/wizard-store-provider';
export default function WizardLayout({ children }: { children: ReactNode }) { return <WizardStoreProvider>{children}</WizardStoreProvider>;}The reason is how the App Router renders nested routes. A segment layout persists across the navigations between its child pages: moving from step 1 to step 2 swaps the page, but the layout, and the provider inside it, stays mounted. So the store held in that provider’s useRef survives the back-and-forward between steps, and the draft you typed on step 1 is still there on step 2. Mount the provider on each page instead and it would unmount and rebuild on every navigation, resetting the store every time the user moves a step. That is the canonical mistake with this pattern.
This is a deliberate divergence from the official guide’s “mount it in the root layout.” That advice is right for an app-wide store. A feature-scoped store mounts on the feature’s segment layout, which is the per-feature scoping rule from the previous lesson, now expressed in the route tree rather than in prose.
Here is the whole shape on disk, so the client boundary is visible: which files carry 'use client', which are pure, and where the provider sits relative to the step pages.
Directorysrc/app/(app)/customers/new/
Directory_lib/
Directorywizard/
- wizard-types.ts slice types + composed
WizardStore, pure types - contact-slice.ts one slice factory, pure, no React
- store.ts
createWizardStore(), vanilla store factory
- wizard-types.ts slice types + composed
Directory_components/
- wizard-store-provider.tsx
'use client', Context +useRef - use-wizard-store.ts the typed
useWizardStore(selector)hook
- wizard-store-provider.tsx
- layout.tsx mounts
<WizardStoreProvider>around the steps - …
Composing the store with slices
Section titled “Composing the store with slices”The factory above held a placeholder where the real state goes. The real state for a wizard is four areas, contact, billing, preferences, and meta, and pouring all four into one flat creator produces an unreadable wall of keys. Slices fix that.
A slice is a self-contained factory for one area: its values and the actions that mutate them, typed on its own. The full store composes the slices together. The payoff is structural: each slice is its own file with a narrow type surface, the store assembly is a thin composition file, and adding a fifth area means adding a file rather than editing one large object that everything already imports.
Here is one slice, with the single TypeScript move that holds the whole pattern together:
import type { StateCreator } from 'zustand';import type { WizardStore } from './wizard-types';
export type ContactSlice = { contact: { firstName: string; email: string; phone: string }; setContactField: ( key: keyof ContactSlice['contact'], value: string, ) => void;};
export const createContactSlice: StateCreator<WizardStore, [], [], ContactSlice> = (set) => ({ contact: { firstName: '', email: '', phone: '' }, setContactField: (key, value) => set((state) => ({ contact: { ...state.contact, [key]: value } })),});ContactSlice is this area’s shape: its values and its actions, nothing from the other slices. One slice, one type, declared on its own.
import type { StateCreator } from 'zustand';import type { WizardStore } from './wizard-types';
export type ContactSlice = { contact: { firstName: string; email: string; phone: string }; setContactField: ( key: keyof ContactSlice['contact'], value: string, ) => void;};
export const createContactSlice: StateCreator<WizardStore, [], [], ContactSlice> = (set) => ({ contact: { firstName: '', email: '', phone: '' }, setContactField: (key, value) => set((state) => ({ contact: { ...state.contact, [key]: value } })),});The generic is the move that makes this work. Of its four type parameters, two matter. The first is the full store type, so set and get inside this slice see every slice, not just this one. The fourth is what this slice returns. The two middle [] are middleware mutator tuples, empty here; we name them once and move on.
import type { StateCreator } from 'zustand';import type { WizardStore } from './wizard-types';
export type ContactSlice = { contact: { firstName: string; email: string; phone: string }; setContactField: ( key: keyof ContactSlice['contact'], value: string, ) => void;};
export const createContactSlice: StateCreator<WizardStore, [], [], ContactSlice> = (set) => ({ contact: { firstName: '', email: '', phone: '' }, setContactField: (key, value) => set((state) => ({ contact: { ...state.contact, [key]: value } })),});The setter spreads its own sub-object, { ...state.contact, [key]: value }, so a write to contact never disturbs billing or preferences. Each slice touches only what it owns.
The StateCreator generic is the part every shortcut tutorial drops, and dropping it is why those tutorials end in any two steps later. Type it once per slice and set/get stay correctly typed against the whole store from then on. It is four lines of ceremony that buy the entire feature its type safety.
The composition file then assembles the slices. Each slice factory receives the same set/get/store triple, so you forward all three with a rest parameter and spread the results together:
import { createStore } from 'zustand/vanilla';import { createContactSlice } from './contact-slice';import { createBillingSlice } from './billing-slice';import { createPreferencesSlice } from './preferences-slice';import { createMetaSlice } from './meta-slice';import { initialWizardState, type WizardStore } from './wizard-types';
export const createWizardStore = () => createStore<WizardStore>()((set, ...rest) => ({ ...createContactSlice(set, ...rest), ...createBillingSlice(set, ...rest), ...createPreferencesSlice(set, ...rest), ...createMetaSlice(set, ...rest), reset: () => set(initialWizardState, true), }));Two things are happening in that creator. The slice spread is the forwarding step: the creator receives (set, get, store), and (set, ...rest) re-packs that exact triple so ...createContactSlice(set, ...rest) hands every slice the same real set, and they all write to one store. Spread the four results into one object and the store is composed. The store-wide reset is the one action that is not a slice’s job, so it lives on the composition itself.
One source-of-truth type ties it together. WizardStore is the intersection of the four slice types, declared once:
import type { ContactSlice } from './contact-slice';import type { BillingSlice } from './billing-slice';import type { PreferencesSlice } from './preferences-slice';import type { MetaSlice } from './meta-slice';
export type WizardState = ContactSlice & BillingSlice & PreferencesSlice & MetaSlice;export type WizardStore = WizardState & { reset: () => void };That is the type flow as a rule: one WizardState for the data and one WizardStore for data plus actions, both composed by intersection from the slice types. Each slice’s StateCreator is parameterized on the full WizardStore, so set and get are correctly typed in every slice. This lesson shows one slice and the composition, and the chapter project fills in the other three the same way.
Selectors: the subscription contract
Section titled “Selectors: the subscription contract”Selectors are where the re-render model becomes concrete, and where the most common performance mistake lives. The rule from the previous lesson, that components subscribe to the slice they render, is enforced entirely by the function you pass the hook.
A component that calls useWizardStore((s) => s.contact.email) re-renders only when contact.email changes. A write to billing.taxId does not touch it, because the subscription is scoped to exactly what was selected. Select the whole store instead, with useWizardStore((s) => s), and you subscribe to every change in every slice, re-rendering on all of them. That single line defeats the entire reason you reached for Zustand. The rule is blunt: subscribe to the slice you render, never the whole store.
This is far easier to believe by watching it than by reading it. The widget below is a three-component slice of the wizard, a progress header, the contact step, and a next button, under two selector strategies. Fire each trigger and watch which badges tick:
In the first tab every write lights all three boxes, because the selector returns a brand-new object on every call and the hook cannot tell the new object from a real change. In the second, a write to contact.email lights only the contact step, and a step change lights only the header and the button. Same store, same writes, and the only difference is how narrowly each component selected. That difference is the whole value of the library.
To keep call sites terse and the selector logic reusable and testable, the experienced move is a selectors.ts file beside the store that exports named selectors:
import type { WizardState } from './wizard-types';
export const selectContactEmail = (s: WizardState) => s.contact.email;export const selectCurrentStep = (s: WizardState) => s.currentStep;A call site then reads useWizardStore(selectContactEmail), where the selector has a name, a test, and one place to change if the shape moves. For a derived value that combines slices, the selector is just a plain function: Zustand re-runs it on every state change but only re-renders the component if the returned value changes. And “changes” is decided by referential equality, which is exactly where the trap hides.
Zustand compares selector results with Object.is . That is perfect for a primitive like s.contact.email, but it breaks the moment a selector returns a freshly built object or array. useWizardStore((s) => ({ a: s.a, b: s.b })) builds a new object literal every call, and Object.is(newObject, oldObject) is always false, so the component re-renders on every store change, even ones that touched neither a nor b. Here is the trap and the two ways out:
const { a, b } = useWizardStore((s) => ({ a: s.a, b: s.b }));Re-renders on every store change. The returned literal is a new reference each call, so Object.is always reports a change.
const a = useWizardStore((s) => s.a);const b = useWizardStore((s) => s.b);Two subscriptions, each stable. Each selector returns a primitive, so Object.is only sees a change when that field actually changes.
import { useShallow } from 'zustand/react/shallow';
const { a, b } = useWizardStore(useShallow((s) => ({ a: s.a, b: s.b })));One subscription, shallow-compared. useShallow compares the result key-by-key, so the new object only counts as a change when one of its values changed.
Reach for atomic selectors when you are pulling two or three fields, and reach for useShallow when the selection is a list or object mapped from a slice, where atomic selectors would mean a variable number of hook calls. And get the import path exact: zustand/react/shallow is the one people misremember.
Actions are the easy half of the read/write split. Because every action is defined once inside the creator, its reference never changes, so selecting an action carries no subscription cost at all. const setContactField = useWizardStore((s) => s.setContactField) reads the function and never re-renders on it. That gives the call site its rule: a component selects the read slices it renders and the actions it calls, and nothing else.
'use client';import { useWizardStore } from './use-wizard-store';import { selectContactEmail } from '../_lib/wizard/selectors';
export function EmailField() { const email = useWizardStore(selectContactEmail); const setContactField = useWizardStore((s) => s.setContactField); return ( <input type="email" value={email} onChange={(e) => setContactField('email', e.target.value)} /> );}The two reads sit side by side and match the rule exactly: selectContactEmail is the one slice this field renders, and setContactField is the one action it calls. The email read re-renders the field when contact.email changes, while the action read never re-renders it at all.
Resetting at the tenancy and submit boundaries
Section titled “Resetting at the tenancy and submit boundaries”A store that holds draft data needs a deliberate way to empty itself, and when you empty it is a correctness decision, not a cleanup afterthought.
Every feature store exposes a reset() that puts state back to its initial values. Implement it with the replace flag, the set(partial, true) we flagged early and deferred:
reset: () => set(initialWizardState, true),The true is what makes it correct. A plain set(initialWizardState) would merge the initial state on top of the current one, leaving any sub-object the initial state does not mention still populated, which silently breaks selectors that assume a slice is in a known shape. The replace flag wipes the whole state object and writes the initial state clean. And in v5 this is not even a choice you can get wrong quietly: because replace: true now requires a complete state object, set({}, true) is a compile error. The type-checker holds the line.
The harder question is when to call it. The store deliberately does not reset on navigation, because that would defeat the cross-route persistence the whole pattern exists to provide, and the draft must survive moving between steps. Instead, you reset at the moments product semantics demand a clean slate. There are three:
- After a successful submit, so “create another customer” opens an empty wizard, not the one you just filed.
- On sign-out, so the next person to use the browser does not inherit a draft.
- On organization switch, so a draft started under one org never carries into another.
The principle underneath all three: a populated client store at a tenant boundary is the same data-isolation failure as the server-side leak, only triggered by a user action instead of a request. You met its twin in the TanStack Query chapter, where queryClient.clear() runs on those same boundaries. This is one cross-chapter discipline, not a new rule: when the tenant changes, the in-memory state from the old tenant has to go.
The middleware lineup, named once
Section titled “The middleware lineup, named once”Zustand ships a handful of middlewares, which are wrappers around the creator that add behavior. You should know their names and, more importantly, the bar each one has to clear before it earns a place. The wizard in this chapter uses none of them, and that restraint is the point: reach for a middleware only when a named trigger applies, the same discipline as not installing TanStack Query until one of its four triggers lands.
persist mirrors the store to browser storage so it survives a refresh. The shape is one wrapper:
persist((set) => ({ /* ...state and actions... */ }), { name: 'cart-v1', storage: createJSONStorage(() => sessionStorage),});The calls that matter: prefer sessionStorage over localStorage for ephemeral session data like a cart, and never persist server state, auth tokens, or anything an org-switch should invalidate. The trap is a hydration mismatch: the server renders the empty initial state, the client rehydrates the persisted state, and React complains they disagree. Gate the first render on a hasHydrated flag so the client waits to swap in persisted state. This course’s wizard does not persist, since losing the draft on refresh is the explicit product call, for reasons the next lesson names, but the middleware is here so you recognize it in the wild.
subscribeWithSelector lets code outside React listen to a slice imperatively, an analytics call on every cart change, for example. It widens subscribe to take a selector:
store.subscribe((s) => s.items, (items, prev) => trackCartChange(items, prev));Reach for it when a non-React listener needs to react to a slice; the wizard has no such listener.
devtools wires the store to the Redux DevTools browser extension so you can watch actions fire as you build, the easiest way to debug a store. Gate it so it never ships to production:
devtools(creator, { enabled: process.env.NODE_ENV !== 'production',});That gate is the same discipline as the TanStack Query devtools: a development tool, never in the production bundle.
Finally, combine and redux exist too. combine infers state from an initial object, and redux bolts a reducer/dispatch shape onto a store. The slices pattern already covers everything you would reach combine for, and redux re-imports the exact ceremony Zustand exists to drop, so neither earns space here. Know they exist; you will not write them.
Putting the store together
Section titled “Putting the store together”Here is the whole skeleton in one place, the five-file shape the chapter project starts from. Read it as a map you now hold: the project is filling it in, not discovering it.
Directorysrc/app/(app)/customers/new/
Directory_lib/
Directorywizard/
- wizard-types.ts slice types + composed
WizardState/WizardStore, pure - contact-slice.ts one slice, billing / preferences / meta mirror it
- selectors.ts named selectors, one place to change if the shape moves
- store.ts
createWizardStorefactory + store-widereset
- wizard-types.ts slice types + composed
Directory_components/
- wizard-store-provider.tsx
'use client', Context +useRefpin - use-wizard-store.ts the typed
useWizardStore(selector)hook
- wizard-store-provider.tsx
- layout.tsx wraps the steps in
<WizardStoreProvider> - … the four step pages
The one piece of this that is genuinely tricky is the request-time order: how the store reaches a component without ever being shared. Lock it in by ordering it yourself:
Order the steps for how the wizard store reaches a component on a single request — without leaking into the next one. Drag the items into the correct order, then press Check.
layout.tsx renders <WizardStoreProvider>. useRef creates a fresh store for this request via createWizardStore(). WizardStoreContext. useWizardStore((s) => s.contact.email). useStore subscribes to the contact.email value. setContactField call writes through set, re-rendering only the email consumer. One quick check on the idea the whole lesson turns on.
A teammate reports that on your SSR app one tenant’s in-progress wizard draft is rendering into another tenant’s response. Each line below ships in the wizard’s store wiring. Which one is the cause of the leak?
export const useWizardStore = create<WizardState>()((set) => ({ /* … */ }));const storeRef = useRef<WizardStore | null>(null);return useStore(store, selector);export const createWizardStore = () => createStore<WizardStore>()((set, ...rest) => ({ ...createContactSlice(set, ...rest) }));export const useWizardStore = create<WizardState>()(...) builds one store the moment the module loads, and the server process reuses that same module across every request — so tenant A’s write sits in tenant B’s render. The other three are the safe wiring. useRef pins a fresh store per component instance (one per request); useStore(store, selector) only reads the store the provider already handed down; and createWizardStore is a factory — it returns a new store each time it’s called, never at module scope. The fix is to delete the module-scoped create and reach the store only through the useRef-pinned provider.The primitives and the wiring are now in place. A store is getState/setState/subscribe with set/get actions. The App Router default is createStore plus a useRef-pinned provider on the feature’s layout. Slices compose with StateCreator, selectors are the subscription contract, and reset runs at the tenant boundaries. The next lesson runs the full three-trigger funnel against the concrete four-step customer wizard and names every product call: back-and-forward preserves the draft, refresh loses it, and the Server-Action submit is the boundary. The chapter project then builds it, file by file, into the map above.
External resources
Section titled “External resources”The official per-request store factory + provider pattern this lesson is built on, for both App and Pages Router.
Official guide to splitting one store into composable slices, with the TypeScript StateCreator typing.
Dominik's canonical opinion piece on custom-hook exports, stable atomic selectors, and separating actions from state.