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.
WizardStore instance, pinned on the shared layoutRun 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.
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 not the four alternatives
Section titled “Why not the four alternatives”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.
Why not URL state
Section titled “Why not URL state”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.
Why not server state
Section titled “Why not server state”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.
Why not just lift state and use useState
Section titled “Why not just lift state and use useState”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.
Why not Redux, Jotai, or Valtio
Section titled “Why not Redux, Jotai, or Valtio”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.
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, andPreferencesSlice, 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 ownscurrentStep,completedSteps, and the navigation actionsgoNext()andgoBack().
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.
The Zod-per-step gate
Section titled “The Zod-per-step gate”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.
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:
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:
WizardStore the assembled draft (four slices) createCustomer Server Action parse → insert → Result router.push('/customers/[id]') + reset() 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.
Navigation: what back/forward keeps, what refresh loses
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.
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.
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.
Anti-patterns on this screen
Section titled “Anti-patterns on this screen”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 silentsessionStoragecopy 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
useEffectinstead of the button handler. That double-submits on re-render. Submit is an event, so put it in the handler. - Reading
currentStepfrom 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 ofset(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.
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: the build
Section titled “Where this goes next: the build”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:
-
The four route segments under
customers/new/with the shared layout. -
The per-step Zod schemas,
contactSchema,billingSchema, andpreferencesSchema, and the derived composite submit schema. -
The four-slice store,
ContactSlice,BillingSlice,PreferencesSlice, andMetaSlice, plus the store-levelreset. -
The
<WizardStoreProvider>mounted on the shared layout, so one instance survives all four navigations. -
Each step’s form wired to the store with the Next-gate, the derived validity selector.
-
The
createCustomerServer Action and the success-reset-then-redirect handoff. -
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.
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.