Skip to content
Chapter 78Lesson 3

The routed wizard, end to end

Putting Zustand to work on a real screen, a four-step routed customer wizard whose draft state the store owns end to end.

You now have both halves of the Zustand story. The first lesson gave you the funnel, the ordered questions an experienced engineer asks before reaching for any client-state library: is this server state, would useState cover it, would lifting plus Context cover it, would the URL cover it, and only then, Zustand. The previous lesson gave you the primitives: createStore, the slices pattern, selector subscriptions, and the useRef-pinned provider that keeps one request’s state from leaking into the next. What’s been missing is a real screen to point both at.

This lesson supplies one. We’ll take a single concrete surface, a four-step “new customer” onboarding wizard living at /customers/new/step-1 through step-4, and turn the abstract triggers into product decisions you can defend out loud. By the end you’ll be able to look at a candidate screen, run the funnel against it, and either justify Zustand with named trade-offs or reject it for a cheaper default. You’ll also walk away with the exact contract the next chapter builds file by file. One thing to set up before we start: we built a multi-step wizard once before, in the forms unit, with a single React Hook Form instance. The difference here is that each step is its own route, and that difference is the whole reason this screen needs a store. We’ll come back to that comparison; for now, just keep it in mind.

The screen: a four-step routed customer wizard

Section titled “The screen: a four-step routed customer wizard”

Before any reasoning runs, let’s make the screen concrete so every later argument has something to point at. The wizard creates a customer across four steps, and each step is 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 everything from steps 1–3, and the final submit.

Those four segments sit under one shared 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 : it doesn’t exist anywhere yet, and the store is the draft’s home for the duration of the flow.

Why split one form into four routes at all? Onboarding a customer is the canonical high-value SaaS form: it gathers a lot of fields, and a tall single page is where users abandon. Breaking it into steps cuts that abandonment and lets validation feedback land per section instead of in one overwhelming wall at the bottom. That’s the product reason the screen exists, the motivation an experienced engineer would give a product manager rather than a point about the stack.

The shape to hold in your head is four routes sitting on top of one store. The diagram below makes it literal.

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.

Run the funnel: why this screen clears the bar

Section titled “Run the funnel: why this screen clears the bar”

An experienced engineer never reaches for a library before justifying it. So before we write a line of store code, we run the first lesson’s funnel against this exact screen. This is the heart of the lesson: the argument for why Zustand earns its place here. The funnel had three triggers, so let’s take each one and hold it up to the wizard.

Trigger one: genuinely shared state across cross-route components. Each step is a separate route segment, and navigating between them replaces the page-level Client Components. Step 4’s review reads the data entered back in steps 1, 2, and 3. Where would that shared data live without a store? The only common ancestor is the layout one level up, so you’d have to thread every step’s fields through the layout as props, which forces every step to become a Client Component receiving every other step’s data. That’s prop drilling wearing a layout’s clothes. This is the strong trigger, and it’s clearly met.

Trigger two: an action surface across disjoint subtrees. Three separate regions of the layout need to touch the store. A header progress indicator reads currentStep. A footer “Next” button reads whether the current step is valid and calls the advance action. The review step reads every slice and fires the submit. That’s three disjoint subtrees sharing one set of actions, and threading callbacks down to all three through the layout is exactly the pain Zustand removes. Also clearly met.

Trigger three: selector versus Context re-render cost. If Context held the draft, every keystroke in step 2’s address field would re-render the header and the footer, because Context re-renders every consumer when any value changes. Selector subscriptions keep that keystroke local to the input. This trigger is met too, but it’s the weakest of the three for a four-step form, and an experienced engineer names that honestly. Trigger three rarely justifies Zustand on its own; here it rides on the back of the first two. You watched the selector model isolate re-renders this way in the previous lesson, and that’s the same mechanism, now earning its keep on a real screen.

Two strong triggers and one supporting one. The screen clears the bar. Walk the funnel yourself in the decision below: picking each answer in order is the habit worth building, more than reading the verdict at the end.

Run the funnel against the wizard

The funnel’s verdict is only as trustworthy as the rejections behind it. What separates a senior engineer here is the ability to say, for each cheaper option, exactly why it loses for this screen. Each of the four below is one tight argument, and naming the trade-off on the surface is what they have in common.

The URL is the default home for shareable view state: filters, sort, the pagination cursor, which is what nuqs is for. So why not encode the wizard draft there too? Because billing data and personal information do not belong in a URL. URLs leak: to server access logs, to browser history, to analytics referrers, to screenshot tools, to the copy-pasted link in a support ticket. A tax ID or an email sitting in a query string is now sitting in five places you didn’t intend. On top of that, encoding an entire multi-step draft as query parameters blows past any reasonable URL length and shape. So the call is clear: the URL is the right tool for view state you’d be happy to share, and the wrong tool for sensitive draft data. This is the first lesson’s “do we even own this on the client” check, answered for this screen. We own it, and the in-memory store is where it belongs.

The wizard is a draft: the customer does not exist until step 4 submits. To persist the partial draft before submit, you’d need a customer_drafts table, a job to garbage-collect abandoned drafts, a way to surface a returning user’s half-finished draft, and tenancy rules on the draft rows. That is product scope, not stack scope: a whole feature, not a state-management decision. The course makes the call explicitly: refresh loses the wizard, by product decision, and that trade gets named on the screen rather than buried. Contrast this with TanStack Query from earlier in the unit, which owns server state the user can watch go stale; a pre-submit draft is not that. Nothing on the server knows this customer is being created, and that’s fine until the moment they click submit.

This is the comparison worth the most room, because it’s the one that will nag at you: didn’t we already build a multi-step wizard in the forms unit? Yes, and it was the right build 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 so back-navigation keeps earlier fields. That pattern is correct precisely because all the steps live on one route, under one Client Component that owns the whole form.

Our wizard is four routes. Step 1 should be a shareable URL: /customers/new/step-1 is a real, deep-linkable address. Browser back and forward should move between steps. And a routed step has no single Client Component sitting above all four step UIs, because navigation swaps the page out from under them. Lifting useState requires exactly that single owner rendering every step, which collapses the routing back into one page. You can have the routing or you can have the single owner, but not both. So the call splits cleanly: routed steps need a store (or something equivalent), while modal steps stay with one useForm. Keep the two patterns side by side rather than as competitors, because they answer different questions.

The first lesson already settled this: Zustand v5 is the 2026 SaaS default, and you only reach for Redux Toolkit when a codebase is already built on it. The conclusion is the same here, so there’s nothing to re-argue.

You’ve now seen Zustand rejected four ways and accepted once. The fastest way to make that judgment automatic is to practice it on cases that aren’t the wizard. Sort each piece of state below into the tool that actually owns it.

Which tool owns each piece of state in our app? Run the funnel on each one before you drop it. Drag each item into the bucket it belongs to, then press Check.

useState One component owns it
nuqs (URL) Shareable view state in the address bar
Server Action / Server Component It already exists on the server
Zustand store Shared, cross-route draft state
The new-customer wizard draft across four routes
The customers list filter and sort
Whether a row’s action menu is open
The saved customer after submit
The wizard’s current valid step
A single contact-edit modal’s fields

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 now lands on a concrete shape. The wizard store is four slices:

  • ContactSlice, BillingSlice, and PreferencesSlice, where each owns its step’s fields, the per-field setters for them, and a sibling Zod schema. You saw the contact slice in full last lesson; the billing and preferences slices mirror it exactly, so the next chapter builds them from the same template.
  • MetaSlice, the cross-cutting one. It owns currentStep, completedSteps, and the navigation actions goNext() and goBack().

One detail to state plainly, so the next chapter stays consistent with what the previous lesson shipped. The store-wide reset stays exactly where the last lesson put it, on the WizardStore type (WizardStore = WizardState & { reset }). The step pointer and the navigation actions, currentStep, completedSteps, goNext, and goBack, live inside MetaSlice. So reset is store-level, and the step machinery is a slice. That mapping matters because the build follows it.

The file layout is the one the previous lesson drew, so rather than redraw the tree, just recall its shape: the four slice files plus wizard-types.ts and store.ts under _lib/wizard/, the provider and the typed hook under _components/, and the provider mounted on customers/new/layout.tsx.

Here’s the one key piece the previous lesson deliberately left for this screen: the per-step validation gate. Each step’s slice has a sibling schema written with Zod 4’s top-level builders, z.email() rather than the deprecated z.string().email(), and the composite submit schema is derived from those three step schemas, never hand-written twice.

The gate itself is where an experienced engineer makes a small but important choice: validity is derived, not stored. One option is to keep an isValid boolean in the slice and a validate() action to update it, but then you have to remember to call that action on every keystroke, and the boolean can drift out of sync. Instead, you express step validity as a selector that runs the current step’s schema against the live slice and reads .success. Because it’s a selector, it recomputes on every relevant state change for free, 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, read through the course’s flat error shape.

The annotated walkthrough below shows the MetaSlice state and that validity selector, the piece the previous lesson saved for here.

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 it’s 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 is what 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, because there’s nothing new to validate there, just a read-only summary.

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. There’s 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 lives in the slice as plain state moves: 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

Notice what the slice does not do: it never calls the router. goNext is a pure state move. The actual route change is paired with it at the call site. The “Next” button reads the validity selector to gate itself, then fires both the store action and the navigation 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>
);
};

The key idea is that the store update (goNext()) and the navigation (router.push) fire together in the same handler, and the button is gated by the derived validity selector. disabled={!isStepValid} reads the live slice through the subscription, so it flips the instant the step becomes valid. Note the hook: it’s useWizardStore(selector) from the per-request provider, never a static .getState(), because this course’s provider pins one store per request and exposes no module-level handle.

The point to carry out of this section is the contract that ties the client and server together: the same schema gates the client at the Next button and validates the server inside the submit action. This is the one-schema-both-sides rule from the forms unit, applied here. The client gate is about UX: it stops the user advancing with bad input and surfaces errors early. The server parse is about correctness: it’s the line that actually decides whether the row is allowed to exist. Both fire, and 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”

This is the cleanest boundary in the lesson, and the one worth getting exactly right: the store never touches the database. It can’t, because it’s client-only, by the rule from the first two lessons. So what happens at step 4?

The submit on step 4 calls a Server Action, createCustomer, with the full composite payload assembled from all four slices. That action re-parses the entire composite schema server-side (correctness, not UX), then runs the insert inside the five-seam shape you already know: parse, authorize, mutate, revalidate, return. It hands back the course’s Result<T>. The store is not in this path at all. It assembled the draft, and the action owns the persistence.

On success, the client does three things in order: it awaits the action’s promise, calls the store’s reset(), and then pushes the router to the new customer’s detail page. That’s the whole handoff:

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.

The rule to internalize: the store owns the draft, the action owns persistence, the redirect closes the loop. Three responsibilities, three owners, no overlap. One watch-out follows directly from it: do not stash the new customer’s id in the store after submit. The id is server state the moment the row exists, so the store has no business holding it. Redirect to the detail page and let the server own it from there.

Section titled “Navigation: what back/forward keeps, what refresh loses”

You need to be able to predict this screen’s behavior precisely, because that prediction is what the design encodes. There are three behaviors to know, plus one mistake that breaks all of them.

Back and forward preserve the draft. Browser back and forward triggers App Router navigation between the four segments. Because the provider sits on the shared layout and the store is held in a useRef, the instance survives those navigations untouched. Fill step 1, advance to step 2, hit back, and step 1 is exactly as you left it. There’s no extra wiring to make this work, with one condition attached: the provider is on the shared layout, not on the page.

That condition is the single most common way this screen breaks, so it’s worth stating clearly here and again in the anti-patterns. Put <WizardStoreProvider> on each step page and the store gets created and destroyed on every navigation, so the draft resets the moment the user clicks Next.

Refresh and exit lose the draft, by design. A hard refresh kills the store: there’s no persist middleware, so the in-memory state is gone. Exiting the wizard entirely, by clicking the logo or hitting back past /customers/new/step-1, unmounts the provider and discards the store. Both are deliberate product calls rather than gaps. Refresh-loses keeps the surface honest about what’s saved, and exit-discards stops stale half-finished drafts from following the user into their next visit.

Scrub through the four events below on the same strip from earlier. Watch the store-state band underneath change, and 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.

Why is refresh-loses the right call rather than a missing feature? Because a draft that survives refresh needs server persistence, and that drags in the entire customer_drafts table with all the garbage-collection cost from “why not server state.” The escape hatch does exist: persist to sessionStorage, which the previous lesson named, is available if product wants it. This wizard deliberately declines it, and the reason is honest: a half-finished customer draft silently lingering after a refresh, especially on a shared computer, is a worse surprise than losing it. Refresh-loses is the intentional, defensible choice.

Reset discipline at the tenancy boundaries

Section titled “Reset discipline at the tenancy boundaries”

The same cross-cutting hygiene the previous lesson named as a cost, and the same discipline TanStack Query needed earlier in the unit, now lands on three concrete boundaries. The store must be reset at each:

  • After submit-success on step 4, so a quick “create another” starts blank instead of showing the previous customer’s data.
  • On sign-out. The provider unmount handles it, but call reset() explicitly for safety, since you don’t want a draft outliving the session.
  • On org-switch. This is the one with real stakes. The wrong tenant’s draft sitting in the client store after switching organizations is a data-isolation bug at the cache layer, the exact same class of bug it would be at the data layer. It is not cosmetic.

This is the same discipline you drew for TanStack Query’s queryClient.clear() at the tenancy boundary, where a per-tenant client cache cannot survive a tenant change. Different surface, identical rule. The reset shape is the one from the previous lesson: set(initialWizardState, true), where the true replace flag tells v5 to check the reset for completeness so a partial reset can’t slip through.

Generic Zustand watch-outs belong to the previous lesson. These are the findings a senior reviewer flags on this specific surface, each one a concrete mistake tied to this screen plus why it’s wrong here:

  • Provider on each step page instead of the shared layout. The store resets on every navigation. This is the canonical mistake, repeated on purpose.
  • Reaching for persist “just in case”. Refresh-loses is the deliberate call, and a silent sessionStorage copy leaves draft data sitting on a shared computer.
  • Stashing the new customer’s id in the store after submit. That’s server state; redirect to the detail page instead.
  • Dropping per-step Zod for “we’ll validate on submit”. Discovering all the errors only at step 4 is the worst possible abandonment UX, and the client gate is the entire point of splitting the form.
  • Submitting from a useEffect instead of the button handler. That double-submits on re-render. Submit is an event, so put it in the handler.
  • Reading currentStep from the URL as the source of truth for reachability. The URL is the source of truth for routing, while the store is the source of truth for which step is valid to reach. They should agree, but the store is what gates reachability, so there’s 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. There isn’t one inside the wizard tree, but a reviewer flags any attempt, because Zustand is client-only by definition.

Two of those cause more trouble than the rest. Check yourself on them below.

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 framing and the contract; the next chapter is the file-by-file build. Everything you’ve reasoned through here becomes something concrete the project constructs against this exact contract:

  1. The four route segments under customers/new/ with the shared layout.

  2. The per-step Zod schemas, contactSchema, billingSchema, and preferencesSchema, and the derived composite submit schema.

  3. The four-slice store, ContactSlice, BillingSlice, PreferencesSlice, and MetaSlice, plus the store-level reset.

  4. The <WizardStoreProvider> mounted on the shared layout, so one instance survives all four navigations.

  5. Each step’s form wired to the store with the Next-gate, the derived validity selector.

  6. The createCustomer Server Action and the success-reset-then-redirect handoff.

  7. The verify recipe: back/forward preserves, refresh loses by design, no double-submit on step 4.

If you want the upstream reference for the provider pattern itself, the useRef-pinned store wired to the App Router, the official Zustand setup guide is the source the previous lesson’s pattern came from.