Skip to content
Chapter 78Lesson 2

Primitives and the per-request provider

The Zustand v5 store API and the App Router wiring that gives each 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: shared client state across disjoint or cross-route trees, with selectors keeping re-renders narrow. This lesson is the how — the v5 store API, plus the one App Router wiring step, the per-request provider, that keeps one tenant’s state from leaking into another’s request.

Zustand ships from a single package:

Terminal window
pnpm add zustand

Before the App Router wiring, look at the store on its own. A Zustand store holds one state object and exposes three functions: getState() reads it, setState() writes it, and subscribe() registers a callback that fires on every write. There are no reducers, action types, or dispatcher; the state and the actions that mutate it live in one closure. React reads the store through a useStore-style hook that re-renders a component only when the slice it selected changes.

The smallest store makes that concrete: a counter written with create, the React-bound form that hands you a ready-to-use hook, so you 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 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 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 the returned keys into the existing state, so you only return what 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, 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.

1 / 1

Those two shapes cover almost every write to set . A third, the rare replace flag set(partial, true), overwrites the whole state instead of merging; in v5 it requires a complete state object, and its one use, resetting a store, comes later.

The other half is get , the read from inside the store. An action reaches for it when it needs 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: a method living next to the values it touches, calling get to read and set to write.

set and get are not React hooks but plain functions on a plain object, which is why a Zustand store works entirely outside React, and why the same store ends up shared across server requests if you let it. The counter above uses create, correct for a React app that never runs on the server; the course’s stack does, and that one fact forces the different form the next section explains.

create vs createStore: why SSR forces a provider

Section titled “create vs createStore: why SSR forces a provider”

create<State>(...) returns a React hook backed by a single store instance that lives at module scope : built the first time the module is imported, reused forever after. In a client-only app that is fine, one browser means one user and one store. On the server it is a trap, because Next.js loads each module once and serves every request from it, so one module-scoped wizard store is shared by every request in flight.

Picture a multi-tenant SaaS: tenant Acme writes email: ada@acme.com into the store, then tenant Globex renders the same route against that same instance and reads Acme’s draft into its own response. That 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 saw the same shape with the module-scoped QueryClient in the TanStack Query chapter: identical diagnosis, different fix.

Request A tenant Acme
writes email: ada@acme.com
Request B tenant Globex
reads the wizard store
write
read
Server process — module scope
wizard store
email: ada@acme.com
name: Ada Acme
Request B reads Acme’s draft
One module-scoped store, leaking across every request the server handles.

The fix is the v5 split, two entry points to choose between:

  • create, from zustand, 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 , from zustand/vanilla, gives you a plain store with getState / setState / subscribe and no React binding. You wrap it yourself in React Context and read it with the generic useStore hook from zustand.

The App Router rule is createStore plus a per-request provider. The provider builds a fresh store for each request, so no shared instance exists for SSR to leak across responses.

// module top — runs once per process
export const useWizardStore = create<WizardState>()((set) => ({
/* ...state and actions... */
}));

One store for the whole process, so tenant A’s draft sits in tenant B’s request.

The per-request store is three small files, each with one job:

  1. The factory, createWizardStore(initialState), built on createStore. Pure, no React, returns a vanilla store.
  2. The provider, a 'use client' component that creates the store once with useRef and hands it down through Context.
  3. The typed hook, useWizardStore(selector), which reads this request’s store from Context and binds it to React with useStore.

The store lives next to its route, not in lib/, where the conventions forbid the React imports the provider and hook need. So the pure, type-only files sit in the route’s _lib/ folder and the two React files in its _components/ folder.

The factory returns a fresh vanilla store; we compose the real slices in the next section. What matters now is that it returns a createStore result rather than calling createStore at module scope.

customers/new/_lib/wizard/store.ts
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 */
}));

createStore is vanilla and WizardStore is a type-only import, so this file carries no 'use client'. The provider calls it, once per request; nothing calls it at module scope.

The provider is where the per-request guarantee lives.

'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 createContext channel carries this request’s store down the tree. Its value type is WizardStore | null: null until the provider sets it, which is what the hook’s guard checks 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.

1 / 1

A module-scoped const wizardStore = createStore(...) is the exact leak from the previous section: one store shared by every request. Pinning it in a useRef instead makes the per-request boundary structural.

The official guide sometimes writes this with a useState lazy initializer, const [store] = useState(() => createWizardStore()), which also runs exactly once and is equivalent. This course standardizes on the useRef form; pick one and do not mix them.

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 .

customers/new/_components/use-wizard-store.ts
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.

customers/new/_components/use-wizard-store.ts
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, instead of a downstream Cannot read properties of null from useStore three frames away.

customers/new/_components/use-wizard-store.ts
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 only when that slice changes.

1 / 1

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.

customers/new/layout.tsx
import type { ReactNode } from 'react';
import { WizardStoreProvider } from './_components/wizard-store-provider';
export default function WizardLayout({ children }: { children: ReactNode }) {
return <WizardStoreProvider>{children}</WizardStoreProvider>;
}

A segment layout persists across 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 in its useRef survives the moves, and the draft you typed on step 1 is still there on step 2. Mount the provider on each page instead and it rebuilds on every navigation, resetting the store at each step. That is the canonical mistake with this pattern.

This diverges from the official guide’s “mount it in the root layout,” which is right for an app-wide store. A feature-scoped store mounts on the feature’s segment layout, the route-tree expression of per-feature scoping.

The shape on disk, showing which files carry 'use client' 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
    • Directory_components/
      • wizard-store-provider.tsx 'use client', Context + useRef
      • use-wizard-store.ts the typed useWizardStore(selector) hook
    • layout.tsx mounts <WizardStoreProvider> around the steps

The factory above left a placeholder where the real state goes. A wizard’s state spans 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 holds one area’s values and the actions that mutate them, typed on its own. Each slice lives in its own file with a narrow type surface, the assembly stays a thin composition file, and adding a fifth area means adding a file rather than editing one large shared object.

Here is one slice:

customers/new/_lib/wizard/contact-slice.ts
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.

customers/new/_lib/wizard/contact-slice.ts
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 } })),
});

StateCreator has four type parameters, two of which 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.

customers/new/_lib/wizard/contact-slice.ts
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.

1 / 1

The composition file then assembles the slices. Every slice factory takes the same set/get/store triple, so forward all three with a rest parameter and spread the results together:

customers/new/_lib/wizard/store.ts
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),
}));

(set, ...rest) re-packs the creator’s real (set, get, store), so every slice writes to the one store, and spreading the four results merges them into it. The store-wide reset is the one action no slice owns, so it lives on the composition itself.

WizardStore is the single source of truth, the intersection of the four slice types, declared once:

customers/new/_lib/wizard/wizard-types.ts
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 };

WizardState is the data, WizardStore adds reset. The chapter project fills in the other three slices the same way.

Selectors: subscribe to the slice you render

Section titled “Selectors: subscribe to the slice you render”

The selector you pass the hook is what scopes a component’s subscription, and getting it wrong is the most common performance mistake.

A component that calls useWizardStore((s) => s.contact.email) re-renders only when contact.email changes; a write to billing.taxId leaves it alone. Select the whole store with useWizardStore((s) => s) and you subscribe to every slice, re-rendering on all of them, which defeats the reason you reached for Zustand. The rule is blunt: subscribe to the slice you render, never the whole store.

The widget below runs 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:

Selector scope is render scope

The first tab returns a fresh object on every call, so every write re-renders all three components; the second selects narrowly, and each write touches only the components that render that field.

To keep call sites terse and the selector logic reusable and testable, put named selectors in a selectors.ts file beside the store:

customers/new/_lib/wizard/selectors.ts
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): the selector has a name, a test, and one place to change if the shape moves. A derived selector that combines slices is a plain function Zustand re-runs on every state change, re-rendering only when its returned value changes, and “changes” is decided by referential equality.

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, so Object.is always reports a change and the component re-renders on every store write, 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.

Reach for atomic selectors when you pull two or three fields, and 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.

Actions are the easy half of the read/write split. Each action is defined once inside the creator, so its reference never changes and selecting it costs no subscription: useWizardStore((s) => s.setContactField) reads the function and never re-renders on it. So a component selects the read slices it renders and the actions it calls, and nothing else.

customers/new/_components/email-field.tsx
'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)}
/>
);
}

Resetting the store at tenant and submit boundaries

Section titled “Resetting the store at tenant and submit boundaries”

A store holding draft data needs a deliberate way to empty itself, and when you empty it is a correctness decision.

Every feature store exposes a reset() that restores the initial values, built on the replace flag introduced earlier:

reset: () => set(initialWizardState, true),

A plain set(initialWizardState) merges over the current state, leaving any sub-object the initial state omits still populated and breaking selectors that expect a known shape; the flag replaces the whole object instead. In v5 you cannot get this wrong quietly: replace: true requires a complete state object, so set({}, true) is a compile error.

The store deliberately does not reset on navigation: the draft must survive moving between steps, the persistence this pattern exists to provide. Reset only where product semantics demand a clean slate:

  • 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 at the browser does not inherit a draft.
  • On organization switch, so a draft started under one org never carries into another.

A populated client store at a tenant boundary is the same data-isolation failure as a server-side leak, triggered by a user action instead of a request, and it is where queryClient.clear() runs too: when the tenant changes, the old tenant’s in-memory state has to go.

Zustand middlewares and when to reach for them

Section titled “Zustand middlewares and when to reach for them”

Zustand ships a handful of middlewares, wrappers around the creator that add behavior. Know their names and the bar each clears before it earns a place. The wizard in this chapter uses none of them: reach for a middleware only when its named trigger applies.

persist mirrors the store to browser storage so it survives a refresh:

persist((set) => ({ /* ...state and actions... */ }), {
name: 'cart-v1',
storage: createJSONStorage(() => sessionStorage),
});

Prefer sessionStorage over localStorage for ephemeral 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 sees them disagree. Gate the first render on a hasHydrated flag so the client waits before swapping in persisted state. This wizard does not persist; losing the draft on refresh is the explicit product call the next lesson explains.

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));

devtools wires the store to the Redux DevTools extension so you can watch actions fire. Gate it out of production:

devtools(creator, {
enabled: process.env.NODE_ENV !== 'production',
});

Same discipline as the TanStack Query devtools: a development tool, never in the production bundle.

combine and redux round out the list. combine infers state from an initial object; redux bolts a reducer/dispatch shape onto a store. The slices pattern covers what you would reach combine for, and redux re-imports the ceremony Zustand exists to drop, so you will write neither.

The full skeleton, the file shape the chapter project starts from:

  • 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 createWizardStore factory + store-wide reset
    • Directory_components/
      • wizard-store-provider.tsx 'use client', Context + useRef pin
      • use-wizard-store.ts the typed useWizardStore(selector) hook
    • layout.tsx wraps the steps in <WizardStoreProvider>
    • the four step pages

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?

customers/new/_lib/wizard/store.ts
export const useWizardStore = create<WizardState>()((set) => ({ /* … */ }));
customers/new/_components/wizard-store-provider.tsx
const storeRef = useRef<WizardStore | null>(null);
customers/new/_components/use-wizard-store.ts
return useStore(store, selector);
customers/new/_lib/wizard/store.ts
export const createWizardStore = () =>
createStore<WizardStore>()((set, ...rest) => ({ ...createContactSlice(set, ...rest) }));

The next lesson runs this wiring against the concrete four-step customer wizard.