Skip to content
Chapter 78Lesson 3

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.

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.

step-1
Contact
step-2
Billing
step-3
Preferences
step-4
Review
one WizardStore instance, pinned on the shared layout
Four routes, one store underneath them.

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.

Run the funnel against the wizard

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, and PreferencesSlice, 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 owns currentStep, completedSteps, and the navigation actions goNext() and goBack().

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 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.

1 / 1

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:

customers/new/_components/next-button.tsx
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.

client WizardStore the assembled draft (four slices)
server createCustomer Server Action parse → insert → Result
client router.push('/customers/[id]') + reset() redirect closes the loop
Three responsibilities, three owners: the store holds the draft, the action persists it, the 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.

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.

step-1
Contact
step-2
Billing
you are here
step-3
Preferences
step-4
Review
one 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.

step-1
Contact
you are here
step-2
Billing
step-3
Preferences
step-4
Review
one 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.

step-1
Contact
you are here
step-2
Billing
step-3
Preferences
step-4
Review
one 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.

step-1
Contact
you are here
step-2
Billing
step-3
Preferences
step-4
Review
one 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 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.

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 silent sessionStorage copy 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 currentStep from 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 of set(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.

Each step-N/page.tsx renders its own <WizardStoreProvider>, and the shared layout renders none.
Step 4 calls createCustomer from a useEffect that runs whenever the review data changes.
The Next button is disabled={!useWizardStore(selectIsStepValid)}, reading validity through the selector.
reset() runs set(initialWizardState, true) after the action resolves successfully.
The single <WizardStoreProvider> lives on customers/new/layout.tsx, above all four step routes.

This lesson is the contract; the next chapter builds it file by file, from the four routes down to the createCustomer action.