Build the store skeleton
Stand up the wizard’s draft store so its four slices accept writes and reset cleanly to empty.
Right now the wizard is a hollow shell: the four route segments load, the progress header reads “Step 1 of 4”, and the inspector mirrors an empty store, but every setter is a no-op stub. This lesson completes the store’s data layer. The store handle will respond to setContactField, setBillingField, setPreferenceField, togglePreferenceChannel, goNext, goBack, and reset; the provided provider keeps one instance alive as you navigate step 1 through 4 and back; and a headless test suite proves each action.
The forms stay inert. No input is wired to a setter yet, so the inspector snapshot keeps mirroring the empty store no matter what you type, and Next stays disabled. That is the deliberate runnable midpoint: you build the engine here, and the forms that drive it land in the next lesson, Wire the forms and the Next-gate.
Your mission
Section titled “Your mission”You write two things: the four slice setter bodies and the store-wide reset. The store factory, the provider, and the hook ship provided, as a reference for the next store you build by hand. The “Coding time” walkthrough explains each; here are the constraints your code must hold to.
Every StateCreator is parameterized on the full WizardStore type, so set and get see the whole store, including reset, which lives on the store rather than on any one slice. The middleware generics stay empty tuples ([]): this chapter ships no persist, devtools, or subscribeWithSelector. Keep completedSteps a plain number[], never a Set: the inspector mirrors the store over postMessage, and a Set does not survive structured-clone serialization. And never store validity on a slice; a slice holds data and setters. Whether a step is valid is derived from its Zod schema, which lands in the next lesson.
paymentTerms enum value.channels untouched./customers/new/step-1 loads the step with the progress indicator reading “Step 1 of 4” and the first pip highlighted.useWizardStore must be used within a WizardStoreProvider error rather than failing silently.Coding time
Section titled “Coding time”Open the four slice files and store.ts, fill in the setter bodies and reset against the brief and the Lesson 2 tests, then come back here. The shapes only stick if you wrestle with them before reading the reference.
Reference solution and walkthrough
The type surface you are building against
Section titled “The type surface you are building against”wizard-types.ts ships complete and read-only, but every slice is typed against it, so read it first. It is the contract: each slice owns its data plus its setters, WizardState is the four-slice intersection, and WizardStore adds the store-level reset. Note initialWizardData at the bottom, the data-only projection of every slice, which reset overlays to wipe the draft.
// The wizard's type surface. Each slice owns its data + setters; validity is// derived in `selectors.ts`, never stored here. `WizardState` is the four-slice// intersection (data + per-slice actions); `WizardStore` adds the store-level// `reset`. `completedSteps` is a `number[]` (never a `Set`) so the snapshot// serializes for the inspector bridge.
export type ContactSlice = { contact: { firstName: string; lastName: string; email: string; phone: string; }; setContactField: <K extends keyof ContactSlice['contact']>( key: K, value: ContactSlice['contact'][K], ) => void;};
export type BillingSlice = { billing: { line1: string; line2: string; city: string; region: string; postalCode: string; country: string; taxId: string; paymentTerms: 'net15' | 'net30' | 'net60'; }; setBillingField: <K extends keyof BillingSlice['billing']>( key: K, value: BillingSlice['billing'][K], ) => void;};
export type PreferencesSlice = { preferences: { channels: Array<'email' | 'sms' | 'inApp'>; defaultCurrency: string; language: 'en-US' | 'en-GB' | 'fr-FR'; }; setPreferenceField: < K extends keyof Omit<PreferencesSlice['preferences'], 'channels'>, >( key: K, value: PreferencesSlice['preferences'][K], ) => void; togglePreferenceChannel: (channel: 'email' | 'sms' | 'inApp') => void;};
export type MetaSlice = { currentStep: number; completedSteps: number[]; goNext: () => void; goBack: () => void;};
export type WizardState = ContactSlice & BillingSlice & PreferencesSlice & MetaSlice;
export type WizardStore = WizardState & { reset: () => void };
// The data-only projection of every slice. `reset` overlays this onto a fresh// spread of the slice factories so the replace-flag write produces a complete// store (blank data + fresh action identities).export const initialWizardData: Pick< WizardState, 'contact' | 'billing' | 'preferences' | 'currentStep' | 'completedSteps'> = { contact: { firstName: '', lastName: '', email: '', phone: '' }, billing: { line1: '', line2: '', city: '', region: '', postalCode: '', country: '', taxId: '', paymentTerms: 'net30', }, preferences: { channels: [], defaultCurrency: 'USD', language: 'en-US' }, currentStep: 1, completedSteps: [],};One detail in PreferencesSlice matters: setPreferenceField’s key is keyof Omit<…, 'channels'>, so it sets defaultCurrency and language but not channels. Channels are a membership set, not a value you overwrite, so they get their own toggle, and the type stops you wiring a checkbox to the wrong setter.
The contact and billing slices: one-field merge
Section titled “The contact and billing slices: one-field merge”These two share a shape. A slice factory receives the StateCreator triple (set, get, and the store handle) and returns the slice’s data and setters. The setter merges a single key onto the slice object:
import type { StateCreator } from 'zustand';import type { ContactSlice, WizardStore,} from '@/app/(app)/customers/new/_lib/wizard/wizard-types';
export const createContactSlice: StateCreator< WizardStore, [], [], ContactSlice> = (set) => ({ contact: { firstName: '', lastName: '', email: '', phone: '' }, setContactField: (key, value) => set((s) => ({ contact: { ...s.contact, [key]: value } })),});The two [] generics between WizardStore and ContactSlice are the input and output middleware tuples, empty because no middleware wraps this store. Typing the creator on WizardStore rather than ContactSlice is what lets set’s updater see the whole store later; here it costs nothing and keeps every slice uniform.
The setter is the part to internalize. set((s) => ({ contact: { ...s.contact, [key]: value } })) spreads the existing contact object, then overwrites the one computed key. Zustand shallow-merges the returned partial into state, so returning { contact: … } replaces the whole contact object; spreading ...s.contact first carries the three fields you are not changing. Drop the spread and you clobber the siblings back to undefined. There is no validate… on the slice: data and setter, nothing else.
Billing is the same pattern over a wider object:
import type { StateCreator } from 'zustand';import type { BillingSlice, WizardStore,} from '@/app/(app)/customers/new/_lib/wizard/wizard-types';
export const createBillingSlice: StateCreator< WizardStore, [], [], BillingSlice> = (set) => ({ billing: { line1: '', line2: '', city: '', region: '', postalCode: '', country: '', taxId: '', paymentTerms: 'net30', }, setBillingField: (key, value) => set((s) => ({ billing: { ...s.billing, [key]: value } })),});The paymentTerms enum needs no special-casing; it is just another key the same merge handles, which is the point of keying the setter generically. A test sets paymentTerms to 'net60' and expects it to land over the initial 'net30'.
The preferences slice: a value setter and a membership toggle
Section titled “The preferences slice: a value setter and a membership toggle”Preferences carries two setters. setPreferenceField is the same one-field merge as contact and billing, restricted by its type to currency and language. togglePreferenceChannel is the new shape: it flips array membership in a single set:
import type { StateCreator } from 'zustand';import type { PreferencesSlice, WizardStore,} from '@/app/(app)/customers/new/_lib/wizard/wizard-types';
export const createPreferencesSlice: StateCreator< WizardStore, [], [], PreferencesSlice> = (set) => ({ preferences: { channels: [], defaultCurrency: 'USD', language: 'en-US' }, setPreferenceField: (key, value) => set((s) => ({ preferences: { ...s.preferences, [key]: value } })), togglePreferenceChannel: (channel) => set((s) => ({ preferences: { ...s.preferences, channels: s.preferences.channels.includes(channel) ? s.preferences.channels.filter((c) => c !== channel) : [...s.preferences.channels, channel], }, })),});The toggle reads as one expression: if the channel is already in the array, filter it out; otherwise append it. Both branches build a fresh array (filter returns a new array and [...channels, channel] builds one), so the existing array is never mutated. That keeps Zustand’s change detection honest and the inspector snapshot a clean copy. The spread of ...s.preferences carries defaultCurrency and language through untouched, the same discipline as the field setters.
The meta slice: step movement with a de-duped trail
Section titled “The meta slice: step movement with a de-duped trail”The meta slice holds the navigation state (which step you are on and which you have completed) plus the two actions that move between them:
import type { StateCreator } from 'zustand';import type { MetaSlice, WizardStore,} from '@/app/(app)/customers/new/_lib/wizard/wizard-types';
export const createMetaSlice: StateCreator<WizardStore, [], [], MetaSlice> = ( set,) => ({ currentStep: 1, completedSteps: [], goNext: () => set((s) => ({ currentStep: s.currentStep + 1, completedSteps: s.completedSteps.includes(s.currentStep) ? s.completedSteps : [...s.completedSteps, s.currentStep], })), goBack: () => set((s) => ({ currentStep: Math.max(1, s.currentStep - 1) })),});goNext does two things in one set: it increments currentStep, and it appends the step you are leaving to completedSteps, but only if it is not already there. That guard matters because the wizard lets you go forward, back, and forward again; without the includes check, walking step 1 → 2 → 1 → 2 would record 1 twice and the progress pips would double-count. When the step is already recorded, goNext returns the existing array reference unchanged, so no needless re-render fires.
goBack clamps with Math.max(1, s.currentStep - 1): it decrements but floors at 1, so pressing Back on step 1 is a no-op rather than a slide into step 0. There is no separate markStepComplete and no validate…; the meta slice is data plus movement, nothing more.
The store factory: composing the slices and authoring reset
Section titled “The store factory: composing the slices and authoring reset”store.ts is where the four slices become one store. The composition and the factory are provided; the single line you author is reset:
import type { StateCreator } from 'zustand';import { createStore } from 'zustand/vanilla';import { createBillingSlice } from '@/app/(app)/customers/new/_lib/wizard/billing-slice';import { createContactSlice } from '@/app/(app)/customers/new/_lib/wizard/contact-slice';import { createMetaSlice } from '@/app/(app)/customers/new/_lib/wizard/meta-slice';import { createPreferencesSlice } from '@/app/(app)/customers/new/_lib/wizard/preferences-slice';import { initialWizardData, type WizardStore,} from '@/app/(app)/customers/new/_lib/wizard/wizard-types';
const composeSlices: StateCreator<WizardStore, [], [], WizardStore> = ( ...a) => ({ ...createContactSlice(...a), ...createBillingSlice(...a), ...createPreferencesSlice(...a), ...createMetaSlice(...a), reset: () => a[0]({ ...composeSlices(...a), ...initialWizardData }, true),});
export const createWizardStore = () => createStore<WizardStore>()(composeSlices);
export type WizardStoreApi = ReturnType<typeof createWizardStore>;The composeSlices = (...a) => ({ ...createContactSlice(...a), … }) line looks unusual but is the standard Zustand slice composition. a is the StateCreator argument tuple, [set, get, store], captured with a rest parameter. Spreading createContactSlice(...a) calls each slice factory with that same triple and spreads its returned object into the store, so all four slices share one set, one get, one store handle. That shared set is why a slice typed on WizardStore can reach the whole store.
reset is the most opaque line in this lesson:
reset: () => a[0]({ ...composeSlices(...a), ...initialWizardData }, true),a[0] is set, the first element of the StateCreator arg tuple. Calling it writes new state.
reset: () => a[0]({ ...composeSlices(...a), ...initialWizardData }, true),Re-running composeSlices with the same args rebuilds a complete store object: fresh data plus brand-new setter and action identities. This is what keeps the setters working after a reset.
reset: () => a[0]({ ...composeSlices(...a), ...initialWizardData }, true),Overlaying initialWizardData blanks every data field and resets the step counters, while the fresh action identities from the line before survive the overlay.
reset: () => a[0]({ ...composeSlices(...a), ...initialWizardData }, true),The second argument true is the replace flag: it tells set to replace the whole state, not shallow-merge a partial, so the result is exactly the complete object you built, with no stale draft fields left behind.
Replace mode beats a casual partial set because of the requirement the test pins down: after reset, the setters and actions must still be callable. A partial set({ ...initialWizardData }) would merge the blank data over the live store and leave the existing functions in place, which works only by luck of merge semantics. Rebuilding the complete store in replace mode states the intent: the store after reset is a fresh store, identities and all.
createWizardStore is the per-request factory. createStore<WizardStore>()(composeSlices) returns a fresh store handle every time it runs; the curried call, createStore<T>() then (initializer), is Zustand’s signature for supplying the state type. That is the whole defense against the cross-request leak.
Why createStore, not create
Section titled “Why createStore, not create”One named import decides whether your store is a process-wide singleton or a per-request instance:
import { create } from 'zustand';
// One store, created when the module first loads — and reused by every// request the Node process serves.export const useWizardStore = create<WizardStore>()(composeSlices);Leaks across requests. create builds the store at module-load time and returns a hook bound to that one instance. On the server that module is shared by every request the process handles, so when two tenants hit the layout at once, they read and write the same draft. This is the per-request leak from chapter 078.
import { createStore } from 'zustand/vanilla';
// A factory. Nothing is created until the provider calls it — once per// mount, so every request gets its own isolated store.export const createWizardStore = () => createStore<WizardStore>()(composeSlices);Isolated per request. createStore is the framework-agnostic factory underneath create. It builds nothing on import and returns a store handle only when you call it, which the provider does once per mount, so two tenants never share memory.
create bundles createStore with a React hook bound to a module-level instance: fine for a truly global client store, wrong for anything that renders on the server per request. The wizard renders on the server, so it uses the vanilla factory and supplies its own provider and hook, the next two files.
The provider: the per-request boundary (provided — study it)
Section titled “The provider: the per-request boundary (provided — study it)”wizard-store-provider.tsx ships complete; two of its branches exist only for the inspector’s debug flags. Read it as the reference implementation of the per-request boundary, the shape you will hand-write the next time a feature needs its own store:
'use client';
import { usePathname } from 'next/navigation';import { createContext, type ReactNode, useRef } from 'react';import { useBroadcastSnapshot } from '@/app/(app)/customers/new/_components/use-broadcast-snapshot';import { createWizardStore, type WizardStoreApi,} from '@/app/(app)/customers/new/_lib/wizard/store';
// The per-request boundary. The Context and the ref hold the store *handle*// (`WizardStoreApi = StoreApi<WizardStore>`), not the state object. The store is// pinned with `useRef` (React-19/Compiler-safe, not `useState`) so a single// instance survives back/forward across the four segments: the layout that// mounts this persists across child navigations. A fresh `createWizardStore()`// per request is what stops one tenant's draft leaking into another's SSR// render.export const WizardStoreContext = createContext<WizardStoreApi | null>(null);
// SCAFFOLD-ONLY module-scoped store for the inspector's `STORE_MODULE_SCOPED`// debug flag. The CANONICAL path never touches this — each provider mount gets// its own `useRef`-pinned `createWizardStore()`, so two sessions never share a// draft. When the flag is on, every mount reuses this single instance instead,// reproducing the Ch078 L2 cross-session leak so a rendered check can observe it.let moduleScopedStore: WizardStoreApi | null = null;const getModuleScopedStore = (): WizardStoreApi => { moduleScopedStore ??= createWizardStore(); return moduleScopedStore;};
type WizardStoreProviderProps = { children: ReactNode; // Both default OFF — the canonical correct architecture. The layout reads the // inspector's cookie-backed debug flags per request and passes them in; only an // explicit toggle flips a buggy mounting strategy into existence. storeModuleScoped?: boolean; providerOnStepPage?: boolean;};
export const WizardStoreProvider = ({ children, storeModuleScoped = false, providerOnStepPage = false,}: WizardStoreProviderProps) => { // `providerOnStepPage` ON: re-pin the store per step page by keying the ref on // the pathname, so navigating step-1 → step-2 → step-1 mounts a fresh store each // time and clears the draft — identical to mounting the provider on each step // page instead of the shared layout (the canonical "draft cleared on nav" bug). // OFF: the pathname is ignored and the single layout-mounted store survives // every navigation. const pathname = usePathname(); const pinKey = providerOnStepPage ? pathname : '';
const storeRef = useRef<{ key: string; store: WizardStoreApi } | null>(null); if (storeRef.current === null || storeRef.current.key !== pinKey) { // `storeModuleScoped` ON yields the shared singleton (cross-session leak); // OFF yields a fresh per-mount instance (the isolated, correct store). storeRef.current = { key: pinKey, store: storeModuleScoped ? getModuleScopedStore() : createWizardStore(), }; }
// Mirror the store to the inspector iframe (the helper is provided). useBroadcastSnapshot(storeRef.current.store);
return ( <WizardStoreContext value={storeRef.current.store}> {children} </WizardStoreContext> );};The provider is a Client Component: it holds the store and calls hooks. The boundary between it and the Server Component layout that mounts it is the App Router split from Server Components as the default.
'use client';
import { usePathname } from 'next/navigation';import { createContext, type ReactNode, useRef } from 'react';import { useBroadcastSnapshot } from '@/app/(app)/customers/new/_components/use-broadcast-snapshot';import { createWizardStore, type WizardStoreApi,} from '@/app/(app)/customers/new/_lib/wizard/store';
// The per-request boundary. The Context and the ref hold the store *handle*// (`WizardStoreApi = StoreApi<WizardStore>`), not the state object. The store is// pinned with `useRef` (React-19/Compiler-safe, not `useState`) so a single// instance survives back/forward across the four segments: the layout that// mounts this persists across child navigations. A fresh `createWizardStore()`// per request is what stops one tenant's draft leaking into another's SSR// render.export const WizardStoreContext = createContext<WizardStoreApi | null>(null);
// SCAFFOLD-ONLY module-scoped store for the inspector's `STORE_MODULE_SCOPED`// debug flag. The CANONICAL path never touches this — each provider mount gets// its own `useRef`-pinned `createWizardStore()`, so two sessions never share a// draft. When the flag is on, every mount reuses this single instance instead,// reproducing the Ch078 L2 cross-session leak so a rendered check can observe it.let moduleScopedStore: WizardStoreApi | null = null;const getModuleScopedStore = (): WizardStoreApi => { moduleScopedStore ??= createWizardStore(); return moduleScopedStore;};
type WizardStoreProviderProps = { children: ReactNode; // Both default OFF — the canonical correct architecture. The layout reads the // inspector's cookie-backed debug flags per request and passes them in; only an // explicit toggle flips a buggy mounting strategy into existence. storeModuleScoped?: boolean; providerOnStepPage?: boolean;};
export const WizardStoreProvider = ({ children, storeModuleScoped = false, providerOnStepPage = false,}: WizardStoreProviderProps) => { // `providerOnStepPage` ON: re-pin the store per step page by keying the ref on // the pathname, so navigating step-1 → step-2 → step-1 mounts a fresh store each // time and clears the draft — identical to mounting the provider on each step // page instead of the shared layout (the canonical "draft cleared on nav" bug). // OFF: the pathname is ignored and the single layout-mounted store survives // every navigation. const pathname = usePathname(); const pinKey = providerOnStepPage ? pathname : '';
const storeRef = useRef<{ key: string; store: WizardStoreApi } | null>(null); if (storeRef.current === null || storeRef.current.key !== pinKey) { // `storeModuleScoped` ON yields the shared singleton (cross-session leak); // OFF yields a fresh per-mount instance (the isolated, correct store). storeRef.current = { key: pinKey, store: storeModuleScoped ? getModuleScopedStore() : createWizardStore(), }; }
// Mirror the store to the inspector iframe (the helper is provided). useBroadcastSnapshot(storeRef.current.store);
return ( <WizardStoreContext value={storeRef.current.store}> {children} </WizardStoreContext> );};The Context carries the store handle, not its state, and defaults to null so the hook can detect a component mounted outside the provider.
'use client';
import { usePathname } from 'next/navigation';import { createContext, type ReactNode, useRef } from 'react';import { useBroadcastSnapshot } from '@/app/(app)/customers/new/_components/use-broadcast-snapshot';import { createWizardStore, type WizardStoreApi,} from '@/app/(app)/customers/new/_lib/wizard/store';
// The per-request boundary. The Context and the ref hold the store *handle*// (`WizardStoreApi = StoreApi<WizardStore>`), not the state object. The store is// pinned with `useRef` (React-19/Compiler-safe, not `useState`) so a single// instance survives back/forward across the four segments: the layout that// mounts this persists across child navigations. A fresh `createWizardStore()`// per request is what stops one tenant's draft leaking into another's SSR// render.export const WizardStoreContext = createContext<WizardStoreApi | null>(null);
// SCAFFOLD-ONLY module-scoped store for the inspector's `STORE_MODULE_SCOPED`// debug flag. The CANONICAL path never touches this — each provider mount gets// its own `useRef`-pinned `createWizardStore()`, so two sessions never share a// draft. When the flag is on, every mount reuses this single instance instead,// reproducing the Ch078 L2 cross-session leak so a rendered check can observe it.let moduleScopedStore: WizardStoreApi | null = null;const getModuleScopedStore = (): WizardStoreApi => { moduleScopedStore ??= createWizardStore(); return moduleScopedStore;};
type WizardStoreProviderProps = { children: ReactNode; // Both default OFF — the canonical correct architecture. The layout reads the // inspector's cookie-backed debug flags per request and passes them in; only an // explicit toggle flips a buggy mounting strategy into existence. storeModuleScoped?: boolean; providerOnStepPage?: boolean;};
export const WizardStoreProvider = ({ children, storeModuleScoped = false, providerOnStepPage = false,}: WizardStoreProviderProps) => { // `providerOnStepPage` ON: re-pin the store per step page by keying the ref on // the pathname, so navigating step-1 → step-2 → step-1 mounts a fresh store each // time and clears the draft — identical to mounting the provider on each step // page instead of the shared layout (the canonical "draft cleared on nav" bug). // OFF: the pathname is ignored and the single layout-mounted store survives // every navigation. const pathname = usePathname(); const pinKey = providerOnStepPage ? pathname : '';
const storeRef = useRef<{ key: string; store: WizardStoreApi } | null>(null); if (storeRef.current === null || storeRef.current.key !== pinKey) { // `storeModuleScoped` ON yields the shared singleton (cross-session leak); // OFF yields a fresh per-mount instance (the isolated, correct store). storeRef.current = { key: pinKey, store: storeModuleScoped ? getModuleScopedStore() : createWizardStore(), }; }
// Mirror the store to the inspector iframe (the helper is provided). useBroadcastSnapshot(storeRef.current.store);
return ( <WizardStoreContext value={storeRef.current.store}> {children} </WizardStoreContext> );};The store is pinned lazily in a useRef. The if (storeRef.current === null …) guard creates it once, on first render, and never again: the lazy-init pattern that survives strict-mode’s double-invoke.
'use client';
import { usePathname } from 'next/navigation';import { createContext, type ReactNode, useRef } from 'react';import { useBroadcastSnapshot } from '@/app/(app)/customers/new/_components/use-broadcast-snapshot';import { createWizardStore, type WizardStoreApi,} from '@/app/(app)/customers/new/_lib/wizard/store';
// The per-request boundary. The Context and the ref hold the store *handle*// (`WizardStoreApi = StoreApi<WizardStore>`), not the state object. The store is// pinned with `useRef` (React-19/Compiler-safe, not `useState`) so a single// instance survives back/forward across the four segments: the layout that// mounts this persists across child navigations. A fresh `createWizardStore()`// per request is what stops one tenant's draft leaking into another's SSR// render.export const WizardStoreContext = createContext<WizardStoreApi | null>(null);
// SCAFFOLD-ONLY module-scoped store for the inspector's `STORE_MODULE_SCOPED`// debug flag. The CANONICAL path never touches this — each provider mount gets// its own `useRef`-pinned `createWizardStore()`, so two sessions never share a// draft. When the flag is on, every mount reuses this single instance instead,// reproducing the Ch078 L2 cross-session leak so a rendered check can observe it.let moduleScopedStore: WizardStoreApi | null = null;const getModuleScopedStore = (): WizardStoreApi => { moduleScopedStore ??= createWizardStore(); return moduleScopedStore;};
type WizardStoreProviderProps = { children: ReactNode; // Both default OFF — the canonical correct architecture. The layout reads the // inspector's cookie-backed debug flags per request and passes them in; only an // explicit toggle flips a buggy mounting strategy into existence. storeModuleScoped?: boolean; providerOnStepPage?: boolean;};
export const WizardStoreProvider = ({ children, storeModuleScoped = false, providerOnStepPage = false,}: WizardStoreProviderProps) => { // `providerOnStepPage` ON: re-pin the store per step page by keying the ref on // the pathname, so navigating step-1 → step-2 → step-1 mounts a fresh store each // time and clears the draft — identical to mounting the provider on each step // page instead of the shared layout (the canonical "draft cleared on nav" bug). // OFF: the pathname is ignored and the single layout-mounted store survives // every navigation. const pathname = usePathname(); const pinKey = providerOnStepPage ? pathname : '';
const storeRef = useRef<{ key: string; store: WizardStoreApi } | null>(null); if (storeRef.current === null || storeRef.current.key !== pinKey) { // `storeModuleScoped` ON yields the shared singleton (cross-session leak); // OFF yields a fresh per-mount instance (the isolated, correct store). storeRef.current = { key: pinKey, store: storeModuleScoped ? getModuleScopedStore() : createWizardStore(), }; }
// Mirror the store to the inspector iframe (the helper is provided). useBroadcastSnapshot(storeRef.current.store);
return ( <WizardStoreContext value={storeRef.current.store}> {children} </WizardStoreContext> );};In the canonical path this is the only branch that fires: a fresh per-mount store, the per-request boundary. The getModuleScopedStore() sibling is scaffold for a debug flag; ignore it when you reproduce this shape.
'use client';
import { usePathname } from 'next/navigation';import { createContext, type ReactNode, useRef } from 'react';import { useBroadcastSnapshot } from '@/app/(app)/customers/new/_components/use-broadcast-snapshot';import { createWizardStore, type WizardStoreApi,} from '@/app/(app)/customers/new/_lib/wizard/store';
// The per-request boundary. The Context and the ref hold the store *handle*// (`WizardStoreApi = StoreApi<WizardStore>`), not the state object. The store is// pinned with `useRef` (React-19/Compiler-safe, not `useState`) so a single// instance survives back/forward across the four segments: the layout that// mounts this persists across child navigations. A fresh `createWizardStore()`// per request is what stops one tenant's draft leaking into another's SSR// render.export const WizardStoreContext = createContext<WizardStoreApi | null>(null);
// SCAFFOLD-ONLY module-scoped store for the inspector's `STORE_MODULE_SCOPED`// debug flag. The CANONICAL path never touches this — each provider mount gets// its own `useRef`-pinned `createWizardStore()`, so two sessions never share a// draft. When the flag is on, every mount reuses this single instance instead,// reproducing the Ch078 L2 cross-session leak so a rendered check can observe it.let moduleScopedStore: WizardStoreApi | null = null;const getModuleScopedStore = (): WizardStoreApi => { moduleScopedStore ??= createWizardStore(); return moduleScopedStore;};
type WizardStoreProviderProps = { children: ReactNode; // Both default OFF — the canonical correct architecture. The layout reads the // inspector's cookie-backed debug flags per request and passes them in; only an // explicit toggle flips a buggy mounting strategy into existence. storeModuleScoped?: boolean; providerOnStepPage?: boolean;};
export const WizardStoreProvider = ({ children, storeModuleScoped = false, providerOnStepPage = false,}: WizardStoreProviderProps) => { // `providerOnStepPage` ON: re-pin the store per step page by keying the ref on // the pathname, so navigating step-1 → step-2 → step-1 mounts a fresh store each // time and clears the draft — identical to mounting the provider on each step // page instead of the shared layout (the canonical "draft cleared on nav" bug). // OFF: the pathname is ignored and the single layout-mounted store survives // every navigation. const pathname = usePathname(); const pinKey = providerOnStepPage ? pathname : '';
const storeRef = useRef<{ key: string; store: WizardStoreApi } | null>(null); if (storeRef.current === null || storeRef.current.key !== pinKey) { // `storeModuleScoped` ON yields the shared singleton (cross-session leak); // OFF yields a fresh per-mount instance (the isolated, correct store). storeRef.current = { key: pinKey, store: storeModuleScoped ? getModuleScopedStore() : createWizardStore(), }; }
// Mirror the store to the inspector iframe (the helper is provided). useBroadcastSnapshot(storeRef.current.store);
return ( <WizardStoreContext value={storeRef.current.store}> {children} </WizardStoreContext> );};The provided helper subscribes to the store and posts each snapshot to the inspector iframe over postMessage. It is why the snapshot panel works without you wiring anything.
Two parts of that file are scaffold, not architecture: getModuleScopedStore and the providerOnStepPage/storeModuleScoped props exist only so the inspector can flip a canonical bug into existence for you to watch. The shape you copy is the bare path: a useRef-pinned createWizardStore() mirrored into Context, nothing else.
The typed hook (provided — study it)
Section titled “The typed hook (provided — study it)”The hook is the only place anything reads the store. It pulls the handle from Context, throws if it is missing, and binds a selector with Zustand’s useStore:
'use client';
import { useContext } from 'react';import { useStore } from 'zustand';import { WizardStoreContext } from '@/app/(app)/customers/new/_components/wizard-store-provider';import type { WizardStore } from '@/app/(app)/customers/new/_lib/wizard/wizard-types';
// The only store access. Reads the store handle from Context, throws if mounted// outside the provider, and binds with `useStore(store, selector)`. The selector// is typed on the full `WizardStore` so the submit button can select `reset`// (which lives on `WizardStore`, not `WizardState`); `WizardState`-typed// selectors still pass by contravariance.export function useWizardStore<T>(selector: (s: WizardStore) => T): T { const store = useContext(WizardStoreContext); if (store === null) { throw new Error('useWizardStore must be used within a WizardStoreProvider'); } return useStore(store, selector);}The if (store === null) throw is the runtime contract behind requirement 11. The Context default is null, so any component that calls useWizardStore outside the provider’s subtree gets a clear, named error instead of silently reading a default store: a bug you catch on the first render rather than one that ships. The selector is typed on the full WizardStore so a caller can select reset; a selector that only touches WizardState still type-checks.
Where the provider lives, and why the progress header already works
Section titled “Where the provider lives, and why the progress header already works”The layout is provided too; reading it closes requirements 8, 10, and 12:
const NewCustomerLayout = async ({ children }: { children: ReactNode }) => { const flags = await readDebugFlags();
return ( <WizardStoreProvider providerOnStepPage={flags.PROVIDER_ON_STEP_PAGE} storeModuleScoped={flags.STORE_MODULE_SCOPED} > <div className="mx-auto max-w-xl space-y-6"> <WizardProgress /> {children} <WizardFooter /> </div> </WizardStoreProvider> );};The provider wraps <WizardProgress />, {children} (the active step page), and <WizardFooter />, all three one level below it on the shared layout. Because the layout persists across the four step navigations, the store inside it does too: that is the whole mechanism behind “navigate forward and back and the draft survives” (reqs 10, 12). The flags are read per request from a cookie and passed straight in; both default off, so the rendered tree is the correct architecture until you flip a flag in the inspector.
It is also why the progress header already reads “Step 1 of 4” with the first pip lit (req 8) before you have wired a single field. WizardProgress is a provided Client Component that consumes the store through two atomic selectors, currentStep and completedSteps, so the moment your slices give those keys real values, it resolves them against the mounted store and renders. It proves the store boots and the provider is mounted above it.
The official spread-composition you reproduce in store.ts, plus the StateCreator typing each slice uses.
createStore from zustand/vanilla and the per-request provider — the no-global-store rule behind this skeleton.
The 'Avoiding recreating the ref contents' lazy-init pattern the provider pins the store with.
Moment of truth
Section titled “Moment of truth”The slices and reset are pure vanilla-Zustand units, exercisable with no React render, so the lesson ships a real test suite. Run it with the project’s lesson runner:
pnpm test:lesson 2The suite creates a fresh store with createWizardStore(), calls your setters and actions, and reads store.getState() back, the only observable your slice bodies produce. It drives requirements 1 through 7: each field setter merges one key and leaves its siblings alone, the channel toggle adds and removes, goNext advances and de-dupes the trail, goBack clamps at 1, and reset returns a complete, still-callable store. Wired correctly, the suite passes green:
✓ lesson-verification/Lesson 2.ts (14 tests)
Test Files 1 passed (1) Tests 14 passed (14)The tests cover the headless store. They cannot reach the provider, the inspector mirror, or the cross-session boundary; those are React-and-runtime behaviors you confirm by hand. Walk this list against /inspector and the browser:
/customers/new/step-1 shows “Step 1 of 4” with the first pip highlighted; the form fields render but are unwired; the footer Next button is disabled.layout.tsx confirms <WizardStoreProvider> wraps <WizardProgress />, {children}, and <WizardFooter /> one level above the step children.PROVIDER_ON_STEP_PAGE debug flag on, then navigate step 1 → 2 → 1: the store clears on each navigation — the canonical “draft wiped on nav” bug. Flip it back off.STORE_MODULE_SCOPED flag on, then repeat a two-session test (two browser profiles or a normal and a private window): session A’s draft leaks into session B. Flip it back off.With the suite green and those checks confirmed, the store’s data layer is done and the wizard navigates on a single live store. The forms still don’t write to it: that is the next lesson, Wire the forms and the Next-gate, where every field binds to the setters you just built.