The routed customer wizard
Running Zustand's decision funnel against a four-step routed customer wizard, then laying out its store contract.
The last two lessons gave you Zustand in two halves: the funnel of questions that decides whether you need a client-state library at all (server state, useState, lifting plus Context, the URL, then Zustand), and the primitives that build the store once you do (createStore, slices, selector subscriptions, and a provider pinned per request).
This lesson runs that funnel against a concrete screen: a four-step “new customer” onboarding wizard at /customers/new/step-1 through step-4.
Each abstract trigger becomes a product decision, so you can justify Zustand with named trade-offs or reject it for something cheaper.
The forms unit built a multi-step wizard on a single React Hook Form instance. Here each step is its own route, and that difference is why this screen needs a store.
A four-step routed customer wizard
Section titled “A four-step routed customer wizard”The wizard creates a customer across four steps, each its own route segment:
- Step 1, Contact (
/customers/new/step-1): first name, last name, email, phone. - Step 2, Billing (
/customers/new/step-2): address fields, tax ID, payment terms. - Step 3, Preferences (
/customers/new/step-3): notification channels (multi-select), default currency, language. - Step 4, Review (
/customers/new/step-4): a read-only summary of steps 1–3, and the final submit.
The four segments share one layout, which makes this a routed wizard . That layout hosts the <WizardStoreProvider> from the previous lesson, so a single store instance survives all four navigations. The customer being built is a draft that lives nowhere else; the store is its home for the flow.
Splitting one long form into four routed steps cuts the abandonment a tall single page invites and lets validation land per section instead of in one wall at the bottom.
WizardStore instance, pinned on the shared layoutRun the three triggers against the wizard
Section titled “Run the three triggers against the wizard”The first lesson’s funnel ended in three triggers for reaching past Context to a store. Hold each against this screen.
Trigger one: state genuinely shared across cross-route components. Each step is its own route segment, so navigating swaps the page-level Client Components, yet step 4’s review reads the data from steps 1, 2, and 3. Without a store, that shared data has only one common ancestor, the layout, so every step’s fields thread through it as props and every step becomes a Client Component holding every other step’s data. Prop drilling, one level removed. The strong trigger, clearly met.
Trigger two: an action surface across disjoint subtrees. Three regions of the layout touch the store: a header progress indicator reads currentStep, a footer “Next” button reads the current step’s validity and calls the advance action, and the review step reads every slice and fires the submit. Threading callbacks to all three through the layout is exactly the pain Zustand removes. Also met.
Trigger three: selector versus Context re-render cost. If Context held the draft, every keystroke in step 2 would re-render the header and footer, since Context re-renders every consumer on any change; selector subscriptions keep the keystroke local to the input. Met, but the weakest of the three here: it rarely justifies a store alone, and rides on the back of the first two.
Two strong triggers and a supporting one clear the bar. Walk the funnel yourself below; picking each answer in order is the habit worth building, more than the verdict at the end.
Not every shared-state screen needs a store. When the consumers sit under one subtree and writes are rare, lifting state to the common ancestor and passing it through Context is the right default. Reach for Zustand only once Context’s re-render cost or the prop-threading actually hurts, which it does here but wouldn’t on a smaller screen.
The wizard clears the bar, and the four product calls fall out of the walk:
- The URL is wrong for sensitive draft data. Billing fields and PII would leak to logs, history, and copy-pasted links.
- Refresh loses the draft, by product decision. There’s no
persist, and a half-finished customer shouldn’t silently linger on a shared computer. - The store owns the draft, the Server Action owns persistence. The store assembles the payload;
createCustomerparses and inserts it. - The store resets at every tenancy boundary. Submit-success, sign-out, and org-switch each clear it, so no tenant’s draft survives a tenant change.
Why a store beats the cheaper options
Section titled “Why a store beats the cheaper options”The walk lands on Zustand, but it’s worth saying why each cheaper default loses on this screen.
The URL is for shareable view state, the filters and sort nuqs manages, not for a draft: a tax ID or email in a query string leaks to logs, history, and copy-pasted links, and a whole multi-step draft would blow past any reasonable URL length. Server state is also out: the customer doesn’t exist until step 4 submits, and persisting a partial draft beforehand means a customer_drafts table, cleanup jobs, restore logic, and tenancy rules, a whole feature rather than a state decision. Refresh losing the wizard is the deliberate trade.
The comparison that nags is useState: didn’t we already build a multi-step wizard in the forms unit? Yes, and it was right for what it was. A modal or single-route wizard is one useForm at the root with <FormProvider>, trigger(fieldNames) to validate per step, and shouldUnregister: false to keep earlier fields, and it works precisely because every step lives on one route under one Client Component that owns the form. Our wizard is four routes, each a deep-linkable URL that back and forward move between. A routed step has no single Client Component above all four step UIs, since navigation swaps the page out from under them, and lifting useState would require exactly that single owner, collapsing the four routes back into one page. You get the routing or the single owner, not both: routed steps need a store, modal steps stay with one useForm.
The store shape: four slices and the Zod gate
Section titled “The store shape: four slices and the Zod gate”The slices pattern from the previous lesson lands on a concrete shape: the wizard store is four slices.
ContactSlice,BillingSlice, andPreferencesSlice, each owning its step’s fields, their per-field setters, and a sibling Zod schema. You saw the contact slice in full last lesson; billing and preferences mirror it exactly.MetaSlice, the cross-cutting one. It ownscurrentStep,completedSteps, and the navigation actionsgoNext()andgoBack().
The store-wide reset stays on the WizardStore type, where the last lesson put it; the step machinery (currentStep, completedSteps, goNext, goBack) lives inside MetaSlice.
The Zod-per-step gate
Section titled “The Zod-per-step gate”The previous lesson left one piece for this screen: the per-step validation gate. Each step’s slice carries a sibling schema written with Zod 4’s top-level builders like z.email(), and the composite submit schema is derived from those three, never hand-written twice.
Express step validity as a selector, not a stored isValid boolean: run the current step’s schema against the live slice and read .success. The selector recomputes on every relevant state change, so the “Next” button enables the instant the slice becomes valid, with nothing to keep in sync. Field errors render from the same safeParse result.
The walkthrough below shows the MetaSlice state and the validity selector. currentStep is 1-based (1 through 4), so it maps straight onto the step-N URL. The steps array pairs each schema with the slice it validates and is indexed with currentStep - 1.
type MetaSlice = { currentStep: number; completedSteps: number[]; goNext: () => void; goBack: () => void;};
const steps = [ { schema: contactSchema, slice: (s: WizardState) => s.contact }, { schema: billingSchema, slice: (s: WizardState) => s.billing }, { schema: preferencesSchema, slice: (s: WizardState) => s.preferences },];
export const selectIsStepValid = (state: WizardState): boolean => { const step = steps[state.currentStep - 1]; return step ? step.schema.safeParse(step.slice(state)).success : true;};The MetaSlice state: which step we’re on (1-based, matching the step-N URL) and which steps the user has finished.
completedSteps gates reachability later, so there’s no jumping ahead to a step you haven’t earned.
type MetaSlice = { currentStep: number; completedSteps: number[]; goNext: () => void; goBack: () => void;};
const steps = [ { schema: contactSchema, slice: (s: WizardState) => s.contact }, { schema: billingSchema, slice: (s: WizardState) => s.billing }, { schema: preferencesSchema, slice: (s: WizardState) => s.preferences },];
export const selectIsStepValid = (state: WizardState): boolean => { const step = steps[state.currentStep - 1]; return step ? step.schema.safeParse(step.slice(state)).success : true;};The three per-step schemas, each paired with the slice it validates: contact under state.contact, billing under state.billing, preferences under state.preferences.
Step 4 (review) has no entry, since it’s a read-only summary with nothing to validate.
type MetaSlice = { currentStep: number; completedSteps: number[]; goNext: () => void; goBack: () => void;};
const steps = [ { schema: contactSchema, slice: (s: WizardState) => s.contact }, { schema: billingSchema, slice: (s: WizardState) => s.billing }, { schema: preferencesSchema, slice: (s: WizardState) => s.preferences },];
export const selectIsStepValid = (state: WizardState): boolean => { const step = steps[state.currentStep - 1]; return step ? step.schema.safeParse(step.slice(state)).success : true;};Validity is derived, not stored. The selector runs the current step’s schema against its own slice: step.slice(state) pulls state.contact (or billing, preferences), never the whole composite state.
No isValid boolean and no validate() action to keep in sync, since it recomputes on every keystroke through the selector subscription.
The step ? … : true guard covers the schema-less review step and any out-of-range index under noUncheckedIndexedAccess.
type MetaSlice = { currentStep: number; completedSteps: number[]; goNext: () => void; goBack: () => void;};
const steps = [ { schema: contactSchema, slice: (s: WizardState) => s.contact }, { schema: billingSchema, slice: (s: WizardState) => s.billing }, { schema: preferencesSchema, slice: (s: WizardState) => s.preferences },];
export const selectIsStepValid = (state: WizardState): boolean => { const step = steps[state.currentStep - 1]; return step ? step.schema.safeParse(step.slice(state)).success : true;};Navigation is a plain state move: goNext records the current step in completedSteps and bumps currentStep.
It updates store state only; pushing the route is the call site’s job, shown next.
The slice never calls the router. goNext is a pure state move; the route change is paired with it at the call site, where the “Next” button gates itself on the validity selector and then fires both together:
const NextButton = () => { const isStepValid = useWizardStore(selectIsStepValid); const currentStep = useWizardStore((s) => s.currentStep); const goNext = useWizardStore((s) => s.goNext); const router = useRouter();
const onClick = () => { goNext(); router.push(`/customers/new/step-${currentStep + 1}`); };
return ( <Button disabled={!isStepValid} onClick={onClick}> Next </Button> );};Note the hook: useWizardStore(selector) from the per-request provider, never a static .getState(), because the provider pins one store per request and exposes no module-level handle.
Carry this contract out of the section: the same schema gates the client at the Next button and validates the server inside the submit action. The client gate is UX, surfacing errors early and stopping a user from advancing with bad input; the server parse is correctness, the line that decides whether the row may exist. Both fire; neither replaces the other.
The submit boundary: the store owns the draft, the action persists
Section titled “The submit boundary: the store owns the draft, the action persists”The store is client-only, so it never touches the database. Step 4’s submit calls the createCustomer Server Action with the full composite payload assembled from all four slices.
The action re-parses the entire composite schema server-side for correctness, then inserts through the five-seam shape: parse, authorize, mutate, revalidate, return. It returns the course’s Result<T>. The store assembled the draft; the action owns persistence.
On success the client does three things in order: await the action’s promise, call the store’s reset(), then push the router to the new customer’s detail page.
WizardStore the assembled draft (four slices) createCustomer Server Action parse → insert → Result router.push('/customers/[id]') + reset() redirect closes the loop One watch-out: do not stash the new customer’s id in the store after submit. The id is server state the moment the row exists, so redirect to the detail page and let the server own it from there.
Navigation: what back/forward keeps, what refresh loses
Section titled “Navigation: what back/forward keeps, what refresh loses”Three behaviors define this screen, and one mistake breaks all three.
Back and forward preserve the draft. Because the provider sits on the shared layout and the store lives in a useRef, the instance survives navigation between the four segments untouched, with no extra wiring.
Putting the provider on a page breaks this. Move <WizardStoreProvider> onto each step page and the store is created and destroyed on every navigation, so the draft resets the moment the user clicks Next.
Refresh and exit lose the draft, by design. With no persist middleware, a hard refresh kills the in-memory store, and exiting the wizard unmounts the provider and discards it. Both are deliberate: they keep the surface honest about what’s saved and stop stale drafts from following the user into a later visit.
Scrub the four events below on the same strip from earlier, and watch the store-state band underneath: notice which events leave it intact and which wipe it.
WizardStore instance, pinned on the shared layout
{ contact: ✓ } preserved Fill step 1, advance to step 2. The store now holds the contact slice, and you carried it across a navigation. Preserved.
WizardStore instance, pinned on the shared layout
{ contact: ✓ } preserved Browser back to step 1. The provider lives on the shared layout, so the useRef-pinned store survives the navigation untouched, and step 1 is exactly as you left it. Preserved.
WizardStore instance, pinned on the shared layout
{ } lost Hard refresh. There’s no persist middleware, so the in-memory store dies with the page, and the draft is gone, by design. Lost.
WizardStore instance, pinned on the shared layout
{ } discarded Exit to /customers, then return. Leaving the wizard unmounts the provider and discards the store, so coming back starts blank. Discarded.
Refresh-loses is a choice, not a missing feature: surviving a refresh would require a server-side customer_drafts table to build and clean up, and a draft silently lingering after a refresh, especially on a shared computer, is a worse surprise than losing it.
Reset the store at every tenancy boundary
Section titled “Reset the store at every tenancy boundary”Reset the store to its initial state at three boundaries:
- After submit-success on step 4, so “create another” starts blank instead of pre-filled with the previous customer.
- On sign-out, so a draft can’t outlive the session. Provider unmount handles this, but reset explicitly for safety.
- On org-switch, so the previous tenant’s draft can’t linger after the active tenant changes.
Reset with set(initialWizardState, true): the true replace flag makes Zustand check the reset for completeness, so a partial reset can’t slip through. Same rule as TanStack Query’s queryClient.clear() at this boundary, applied to a different surface.
Anti-patterns on this screen
Section titled “Anti-patterns on this screen”Generic Zustand watch-outs belong to the previous lesson. These are what a senior reviewer flags on this surface:
- Provider on each step page instead of the shared layout. The store resets on every navigation.
- Reaching for
persist“just in case”. Losing the draft on refresh is the deliberate call, and a silentsessionStoragecopy leaves draft data on a shared computer. - Stashing the new customer’s id in the store after submit. That’s server state; redirect to the detail page.
- Dropping per-step Zod for “we’ll validate on submit”. Surfacing every error only at step 4 is the worst abandonment UX, and the per-step gate is why you split the form.
- Submitting from a
useEffect. It double-submits on re-render; submit is an event, so put it in the button handler. - Reading
currentStepfrom the URL as the source of truth for reachability. The URL routes; the store gates, so no skipping to step 3 while step 2 is invalid. - Clearing with
set({})instead ofset(initialWizardState, true). A partial state object breaks every selector that assumes its slice is present. - Querying the store from a Server Component. Zustand is client-only.
A teammate’s wizard branch loses the draft on every Next click and occasionally creates two customers from one submit. Which two choices in their code produce those symptoms? Select all that apply.
step-N/page.tsx renders its own <WizardStoreProvider>, and the shared layout renders none.createCustomer from a useEffect that runs whenever the review data changes.disabled={!useWizardStore(selectIsStepValid)}, reading validity through the selector.reset() runs set(initialWizardState, true) after the action resolves successfully.<WizardStoreProvider> lives on customers/new/layout.tsx, above all four step routes.useRef-pinned store is recreated and thrown away on every navigation, so the draft vanishes the instant the user advances — the fix is one provider on the shared layout. Submitting from useEffect turns a one-time user event into something that fires on every re-render, double-submitting the customer — the fix is to call createCustomer from the button’s onClick. The other three are the correct patterns: deriving validity with a selector, resetting with the true replace flag, and pinning the single provider on the shared layout so back/forward preserves the draft.Where this goes next
Section titled “Where this goes next”This lesson is the contract; the next chapter builds it file by file, from the four routes down to the createCustomer action.
External resources
Section titled “External resources”The per-request provider pattern, from the source — why the store is created per request and mounted on the layout.
A Zustand maintainer on sharing the store instance (not its values) through Context — the exact pattern this wizard mounts on its layout.
The canonical best-practices deep-dive: atomic selectors, actions as events, and deriving instead of storing.