Wire the forms and the Next-gate
Make each step’s form write into its slice, show inline field errors, and let Next advance only when the current step’s data is valid.
Last lesson left you with a live engine and no controls. The store accepts writes, the provider keeps one instance alive across the four routes, the inspector mirrors it — but every field is static markup, so typing changes nothing and Next is hardcoded-disabled. This lesson connects the two. By the end, typing into a field persists its value in the slice and re-renders only that one field; a bad value shows a short error underneath it and keeps Next disabled; once the whole step parses, Next enables and one click advances both the URL and the store; pressing Back returns with your draft intact.
Your mission
Section titled “Your mission”Every field on steps 1 through 3 becomes its own small client component, and that decomposition is the whole point — not an organizational nicety. Each field subscribes to exactly what it reads: one atomic selector for its value, the setter it writes through, and one primitive for its own error. Because each field owns its subscription, a keystroke in one input re-renders only that input’s component — its siblings stay flat, and the progress header above never re-renders mid-typing. The cheap, lazy alternative — one component reading the whole slice — re-renders all four fields on every character, and the inspector’s render-counter panel makes the difference impossible to miss. Reach for the atomic selector by default; it is the shape that keeps re-renders surgical, and decomposing the step into per-field components is what makes “only the changed field re-renders” literally true rather than aspirational.
Validity and field errors are derived, never stored. A slice holds data and setters; whether the slice is valid is a question you answer by running its Zod schema over it inside selectors.ts, on demand. The per-step schema is the single source of truth — the same contactSchema that gates the form here re-parses the composite payload server-side in the next lesson, so there is exactly one definition of “valid contact.” Keep the validation whole-slice, keyed by the current step, rather than tracking which individual fields the user has “touched”: it keeps the model small and the selector shape clean, and the error map naturally only lists fields that actually failed.
Two performance traps sit on either side of this, and the lesson exists partly to teach you to walk between them. The first is the one above — a whole-slice read that re-renders every field on every keystroke. The second is sharper and crashes the page outright: a field that subscribes to the whole error object rather than its own error string. The error map is rebuilt fresh every time it is derived, so a selector returning that object fails React’s reference check on every store read, re-renders, re-derives, gets another fresh object, and loops — React’s infinite-loop guard catches the runaway and throws. The fix is to read each field’s error as a single primitive value: one string (or nothing), which compares cleanly and re-renders only when that field’s first error message actually changes. Read the field’s own error, never subscribe to the whole error object.
The footer’s Next-gate is the same discipline applied to validity. It derives a single primitive boolean from the current step’s whole-slice validation, so the footer re-renders only when validity flips, not on every keystroke that leaves the step still-invalid — even though the validation itself runs anew on every store change, the boolean it collapses to stays stable until validity actually changes. Wire Next’s click to advance both the store’s step and the URL together in one handler, rather than splitting them across an effect that watches the step. That bundling is canonical for a routed wizard. Splitting it — firing the navigation from an effect that watches the current step — is the effects-as-orchestrators pattern this course rejects; keep the cause and its two effects in the one handler the user triggered, explicit over magic.
Keep the gate honest about what it is: UX, not a security boundary. It exists to stop a confused user from advancing with bad data. It is not what protects the write — the action re-parses the same schema server-side next lesson, so a payload that skips the gate still fails at the boundary and returns an error. Render errors as short text-destructive text under the field, no toast, matching the form UX baseline you set earlier in the course. A couple of fields are deliberately lean: paymentTerms is a three-option select and country a two-letter text input (a real country picker in production, kept minimal here), and channels is three checkboxes bound to the toggle, not a value setter. The step-4 review and any submit are out of scope — that is the next lesson.
Coding time
Section titled “Coding time”Write selectors.ts first, then wire the three step pages and the footer against the brief and the Lesson 3 tests. The selector logic is the part the tests pin down and the part everything else reads from, so get it right before you touch a component.
Reference solution and walkthrough
The selectors: where validity is derived
Section titled “The selectors: where validity is derived”Everything in this lesson hangs off selectors.ts. It is the only place a Zod schema meets the live store, and it exposes that meeting through three kinds of selector: the atomic field reads, the step-validity boolean, and the field-error map. Here is the whole file:
import { z } from 'zod';import { billingSchema, contactSchema, preferencesSchema,} from '@/app/(app)/customers/new/_lib/wizard/schemas';import type { WizardState } from '@/app/(app)/customers/new/_lib/wizard/wizard-types';
export const selectCurrentStep = (s: WizardState) => s.currentStep;
export const selectContactFirstName = (s: WizardState) => s.contact.firstName;export const selectContactLastName = (s: WizardState) => s.contact.lastName;export const selectContactEmail = (s: WizardState) => s.contact.email;export const selectContactPhone = (s: WizardState) => s.contact.phone;
type Step = { schema: z.ZodType; slice: (s: WizardState) => unknown };
const steps: readonly Step[] = [ { schema: contactSchema, slice: (s) => s.contact }, { schema: billingSchema, slice: (s) => s.billing }, { schema: preferencesSchema, slice: (s) => s.preferences },];
export const selectIsStepValid = (state: WizardState): boolean => { const step = steps[state.currentStep - 1]; return step ? step.schema.safeParse(step.slice(state)).success : true;};
export const selectStepErrors = ( state: WizardState,): Record<string, string[]> => { const step = steps[state.currentStep - 1]; if (!step) { return {}; } const result = step.schema.safeParse(step.slice(state)); return result.success ? {} : z.flattenError(result.error).fieldErrors;};The steps array is the spine. It pairs each step’s schema with a function that pulls that step’s slice off the store — three entries, indexed zero-based, so step 1 is steps[0]. There is deliberately no fourth entry: step 4 is the review, and a review has no schema. That single omission is what keeps the gate from blocking step 4, and it falls out for free from the lookup.
selectIsStepValid reads as one expression once you see the shape. It indexes steps[currentStep - 1]; if a step entry exists, it runs schema.safeParse(slice) and returns the .success boolean; if the lookup is undefined — which happens only on step 4 — it falls back to true. The step ? … : true fallback is the entire reason the review screen is never gated. And the value it returns is a primitive boolean, which is the property the footer leans on: safeParse runs a fresh result object every store change, but Object.is(true, true) is true, so a subscriber to this selector only re-renders when validity actually flips.
selectStepErrors is the same lookup with a different payload. On step 4 (no entry) it returns {}. Otherwise it parses, and on success returns {} again — a valid slice has no errors. Only on failure does it call z.flattenError(result.error).fieldErrors, which is Zod’s helper for turning a parse error into a flat Record<string, string[]> keyed by field name, each holding that field’s messages. A field that passed is simply absent from the map. That flat shape is exactly what lets each field component read its own error as a primitive, which is the next file.
Step 1: per-field components and the atomic error read
Section titled “Step 1: per-field components and the atomic error read”Step 1 is the template the other two steps follow, so it carries the most explanation. The file is four near-identical field components composed by a parent that subscribes to nothing. Here is FirstNameField, the one to study closely:
'use client';
import { useBroadcastRender } from '@/app/(app)/customers/new/_components/use-broadcast-render';import { useWizardStore } from '@/app/(app)/customers/new/_components/use-wizard-store';import { selectStepErrors } from '@/app/(app)/customers/new/_lib/wizard/selectors';import { Input } from '@/components/ui/input';import { Label } from '@/components/ui/label';
const FirstNameField = () => { const firstName = useWizardStore((s) => s.contact.firstName); const setContactField = useWizardStore((s) => s.setContactField); const error = useWizardStore((s) => selectStepErrors(s).firstName?.[0]); useBroadcastRender('firstName');
return ( <div className="space-y-2"> <Label htmlFor="firstName">First name</Label> <Input id="firstName" data-testid="field-firstName" value={firstName} onChange={(e) => setContactField('firstName', e.target.value)} /> {error ? ( <p data-testid="error-firstName" className="text-sm text-destructive"> {error} </p> ) : null} </div> );};The atomic value read. The selector returns a single string, so the component re-renders only when this field’s value changes — not when any other contact field does. This is the subscription that keeps re-renders surgical.
'use client';
import { useBroadcastRender } from '@/app/(app)/customers/new/_components/use-broadcast-render';import { useWizardStore } from '@/app/(app)/customers/new/_components/use-wizard-store';import { selectStepErrors } from '@/app/(app)/customers/new/_lib/wizard/selectors';import { Input } from '@/components/ui/input';import { Label } from '@/components/ui/label';
const FirstNameField = () => { const firstName = useWizardStore((s) => s.contact.firstName); const setContactField = useWizardStore((s) => s.setContactField); const error = useWizardStore((s) => selectStepErrors(s).firstName?.[0]); useBroadcastRender('firstName');
return ( <div className="space-y-2"> <Label htmlFor="firstName">First name</Label> <Input id="firstName" data-testid="field-firstName" value={firstName} onChange={(e) => setContactField('firstName', e.target.value)} /> {error ? ( <p data-testid="error-firstName" className="text-sm text-destructive"> {error} </p> ) : null} </div> );};The setter is a stable function reference that never changes, so subscribing to it costs nothing — it never triggers a re-render. The onChange calls it with the field key and the new value.
'use client';
import { useBroadcastRender } from '@/app/(app)/customers/new/_components/use-broadcast-render';import { useWizardStore } from '@/app/(app)/customers/new/_components/use-wizard-store';import { selectStepErrors } from '@/app/(app)/customers/new/_lib/wizard/selectors';import { Input } from '@/components/ui/input';import { Label } from '@/components/ui/label';
const FirstNameField = () => { const firstName = useWizardStore((s) => s.contact.firstName); const setContactField = useWizardStore((s) => s.setContactField); const error = useWizardStore((s) => selectStepErrors(s).firstName?.[0]); useBroadcastRender('firstName');
return ( <div className="space-y-2"> <Label htmlFor="firstName">First name</Label> <Input id="firstName" data-testid="field-firstName" value={firstName} onChange={(e) => setContactField('firstName', e.target.value)} /> {error ? ( <p data-testid="error-firstName" className="text-sm text-destructive"> {error} </p> ) : null} </div> );};The atomic error read — the load-bearing line. It reaches into the error map for this field’s first message: a single string, or undefined. Subscribing to the primitive, not the map, is what keeps this from looping (see the callout below).
'use client';
import { useBroadcastRender } from '@/app/(app)/customers/new/_components/use-broadcast-render';import { useWizardStore } from '@/app/(app)/customers/new/_components/use-wizard-store';import { selectStepErrors } from '@/app/(app)/customers/new/_lib/wizard/selectors';import { Input } from '@/components/ui/input';import { Label } from '@/components/ui/label';
const FirstNameField = () => { const firstName = useWizardStore((s) => s.contact.firstName); const setContactField = useWizardStore((s) => s.setContactField); const error = useWizardStore((s) => selectStepErrors(s).firstName?.[0]); useBroadcastRender('firstName');
return ( <div className="space-y-2"> <Label htmlFor="firstName">First name</Label> <Input id="firstName" data-testid="field-firstName" value={firstName} onChange={(e) => setContactField('firstName', e.target.value)} /> {error ? ( <p data-testid="error-firstName" className="text-sm text-destructive"> {error} </p> ) : null} </div> );};The provided helper posts a render event to the inspector’s counter panel on every commit. It is how you see that only the typed field re-renders; it does nothing outside the iframe.
The error renders as a short <p className="text-sm text-destructive"> directly under the input, only when error is truthy — the inline, no-toast form baseline. The other three fields are the same component over a different key. The parent that composes them subscribes to nothing that changes on a keystroke, so it never re-renders mid-typing:
const Step1Page = () => ( <div data-testid="step-1" className="space-y-4"> <h2 className="text-lg font-medium">Contact</h2> <FirstNameField /> <LastNameField /> <EmailField /> <PhoneField /> </div>);
export default Step1Page;That is the literal mechanism behind “typing in one field re-renders only that field”: the value, the setter, and the error each live inside the field component, and the parent holds no subscription, so React has nothing to re-render above the field you are editing.
The atomic read vs. the whole-slice read
Section titled “The atomic read vs. the whole-slice read”It is worth seeing the wrong version next to the right one, because the wrong version type-checks, renders, and looks reasonable — it just quietly re-renders every field on every keystroke:
const ContactFields = () => { // One component, one subscription to the whole slice + setter. const { firstName, lastName, email, phone, setContactField } = useWizardStore((s) => ({ ...s.contact, setContactField: s.setContactField })); // ...renders all four inputs};Re-renders on every keystroke. The selector returns a fresh object literal each time it runs, so the default Object.is check fails on every store change and the whole component — all four inputs — re-renders for a single character typed into one of them. Convenient to write, and exactly the cost the per-field split exists to avoid.
const FirstNameField = () => { // Its own component, subscribing to one value + the setter. const firstName = useWizardStore((s) => s.contact.firstName); const setContactField = useWizardStore((s) => s.setContactField); // ...renders one input};Only the changed field re-renders. Each selector returns a primitive (or the stable setter reference), so Object.is holds for every field except the one whose value actually changed. Decomposing the step into per-field components is what makes the surgical re-render real.
You will confirm both sides of this in the Moment of truth: the render counter shows one field ticking while its siblings stay flat, and temporarily swapping a field’s selector to useWizardStore((s) => s.contact) makes all four fields light up per keystroke.
Step 2: the same pattern over eight billing fields
Section titled “Step 2: the same pattern over eight billing fields”Step 2 is step 1 widened. Eight billing controls, each its own component writing through setBillingField, with two that differ from a plain text input. Here is the file abbreviated to the shapes that matter:
const Line2Field = () => { const line2 = useWizardStore((s) => s.billing.line2); const setBillingField = useWizardStore((s) => s.setBillingField); useBroadcastRender('line2');
return ( <div className="space-y-2"> <Label htmlFor="line2">Address line 2</Label> <Input id="line2" data-testid="field-line2" value={line2} onChange={(e) => setBillingField('line2', e.target.value)} /> </div> );};
const CountryField = () => { const country = useWizardStore((s) => s.billing.country); const setBillingField = useWizardStore((s) => s.setBillingField); const error = useWizardStore((s) => selectStepErrors(s).country?.[0]); useBroadcastRender('country');
return ( <div className="space-y-2"> <Label htmlFor="country">Country (2-letter)</Label> <Input id="country" maxLength={2} data-testid="field-country" value={country} onChange={(e) => setBillingField('country', e.target.value)} /> {error ? ( <p data-testid="error-country" className="text-sm text-destructive"> {error} </p> ) : null} </div> );};
const PaymentTermsField = () => { const paymentTerms = useWizardStore((s) => s.billing.paymentTerms); const setBillingField = useWizardStore((s) => s.setBillingField); useBroadcastRender('paymentTerms');
return ( <div className="space-y-2"> <Label htmlFor="paymentTerms">Payment terms</Label> <select id="paymentTerms" data-testid="field-paymentTerms" className="w-full rounded-md border bg-background px-2 py-1.5 text-sm" value={paymentTerms} onChange={(e) => setBillingField( 'paymentTerms', e.target.value as BillingSlice['billing']['paymentTerms'], ) } > <option value="net15">Net 15</option> <option value="net30">Net 30</option> <option value="net60">Net 60</option> </select> </div> );};Three things to notice. Line2Field reads no error and renders no error paragraph, because line2 is the one billing field with no .min(1) in the schema — an empty second address line is valid, so it can never produce a message. CountryField caps input at two characters with maxLength={2} to match its z.string().length(2) rule. And PaymentTermsField is a <select> whose onChange casts e.target.value to the enum type before handing it to the setter — the select can only emit one of the three option values, so the cast is sound and keeps the typed setter happy. The other five fields (line1, city, region, postalCode, taxId) are byte-for-byte the FirstNameField shape over their own keys.
Step 3: selects and the channel toggle
Section titled “Step 3: selects and the channel toggle”Step 3 has three controls: two value selects and a set of checkboxes. The selects are the PaymentTermsField shape. The checkboxes are different — they bind to togglePreferenceChannel, not a value setter, because channels are a membership set:
const ChannelsField = () => { const channels = useWizardStore((s) => s.preferences.channels); const togglePreferenceChannel = useWizardStore( (s) => s.togglePreferenceChannel, ); useBroadcastRender('channels');
return ( <fieldset className="space-y-2"> <legend className="text-sm font-medium">Notification channels</legend> <div className="flex items-center gap-2"> <Checkbox id="channel-email" data-testid="channel-email" checked={channels.includes('email')} onCheckedChange={() => togglePreferenceChannel('email')} /> <Label htmlFor="channel-email">Email</Label> </div> <div className="flex items-center gap-2"> <Checkbox id="channel-sms" data-testid="channel-sms" checked={channels.includes('sms')} onCheckedChange={() => togglePreferenceChannel('sms')} /> <Label htmlFor="channel-sms">SMS</Label> </div> <div className="flex items-center gap-2"> <Checkbox id="channel-inApp" data-testid="channel-inApp" checked={channels.includes('inApp')} onCheckedChange={() => togglePreferenceChannel('inApp')} /> <Label htmlFor="channel-inApp">In-app</Label> </div> </fieldset> );};Each checkbox derives its checked state from channels.includes(...) and flips it through the toggle — the same toggle you built in the slice last lesson. The schema requires at least one channel (z.array(...).min(1)), so an empty set keeps the gate closed; checking any one box parses the step and enables Next.
The footer: the Next-gate and the bundled handler
Section titled “The footer: the Next-gate and the bundled handler”The footer is where validity becomes a button and one click moves the wizard forward. It reads three things from the store — the current step, the validity boolean, and the two navigation actions — and owns the router:
'use client';
import type { Route } from 'next';import { useRouter } from 'next/navigation';import { useBroadcastRender } from '@/app/(app)/customers/new/_components/use-broadcast-render';import { useWizardStore } from '@/app/(app)/customers/new/_components/use-wizard-store';import { selectCurrentStep, selectIsStepValid,} from '@/app/(app)/customers/new/_lib/wizard/selectors';import { Button } from '@/components/ui/button';
const TOTAL_STEPS = 4;
export const WizardFooter = () => { const currentStep = useWizardStore(selectCurrentStep); const isValid = useWizardStore(selectIsStepValid); const goNext = useWizardStore((s) => s.goNext); const goBack = useWizardStore((s) => s.goBack); const router = useRouter();
// Report the footer's renders so the inspector's re-render-counter panel can // show it re-renders at most once per keystroke burst — only when the // Next-gate boolean (`isValid`) flips, not on every character typed. useBroadcastRender('footer');
const onBack = () => { goBack(); router.push(`/customers/new/step-${currentStep - 1}` as Route); };
const onNext = () => { goNext(); router.push(`/customers/new/step-${currentStep + 1}` as Route); };
return ( <div className="flex items-center justify-between gap-2 border-t pt-4"> {currentStep > 1 ? ( <Button type="button" variant="outline" data-testid="wizard-back" onClick={onBack} > Back </Button> ) : ( <span /> )} {currentStep < TOTAL_STEPS ? ( <Button type="button" data-testid="wizard-next" disabled={!isValid} onClick={onNext} > Next </Button> ) : ( <span /> )} </div> );};Subscribing to the primitive .success boolean. Although safeParse runs a fresh result on every store change, the boolean compares with Object.is, so the footer re-renders only when validity flips — not on every keystroke that leaves the step invalid.
'use client';
import type { Route } from 'next';import { useRouter } from 'next/navigation';import { useBroadcastRender } from '@/app/(app)/customers/new/_components/use-broadcast-render';import { useWizardStore } from '@/app/(app)/customers/new/_components/use-wizard-store';import { selectCurrentStep, selectIsStepValid,} from '@/app/(app)/customers/new/_lib/wizard/selectors';import { Button } from '@/components/ui/button';
const TOTAL_STEPS = 4;
export const WizardFooter = () => { const currentStep = useWizardStore(selectCurrentStep); const isValid = useWizardStore(selectIsStepValid); const goNext = useWizardStore((s) => s.goNext); const goBack = useWizardStore((s) => s.goBack); const router = useRouter();
// Report the footer's renders so the inspector's re-render-counter panel can // show it re-renders at most once per keystroke burst — only when the // Next-gate boolean (`isValid`) flips, not on every character typed. useBroadcastRender('footer');
const onBack = () => { goBack(); router.push(`/customers/new/step-${currentStep - 1}` as Route); };
const onNext = () => { goNext(); router.push(`/customers/new/step-${currentStep + 1}` as Route); };
return ( <div className="flex items-center justify-between gap-2 border-t pt-4"> {currentStep > 1 ? ( <Button type="button" variant="outline" data-testid="wizard-back" onClick={onBack} > Back </Button> ) : ( <span /> )} {currentStep < TOTAL_STEPS ? ( <Button type="button" data-testid="wizard-next" disabled={!isValid} onClick={onNext} > Next </Button> ) : ( <span /> )} </div> );};The bundled handler — the canonical shape. One user click does two things in order: advance the store with goNext, then push the URL to the next segment. They belong together in the handler the user triggered, not split across an effect.
'use client';
import type { Route } from 'next';import { useRouter } from 'next/navigation';import { useBroadcastRender } from '@/app/(app)/customers/new/_components/use-broadcast-render';import { useWizardStore } from '@/app/(app)/customers/new/_components/use-wizard-store';import { selectCurrentStep, selectIsStepValid,} from '@/app/(app)/customers/new/_lib/wizard/selectors';import { Button } from '@/components/ui/button';
const TOTAL_STEPS = 4;
export const WizardFooter = () => { const currentStep = useWizardStore(selectCurrentStep); const isValid = useWizardStore(selectIsStepValid); const goNext = useWizardStore((s) => s.goNext); const goBack = useWizardStore((s) => s.goBack); const router = useRouter();
// Report the footer's renders so the inspector's re-render-counter panel can // show it re-renders at most once per keystroke burst — only when the // Next-gate boolean (`isValid`) flips, not on every character typed. useBroadcastRender('footer');
const onBack = () => { goBack(); router.push(`/customers/new/step-${currentStep - 1}` as Route); };
const onNext = () => { goNext(); router.push(`/customers/new/step-${currentStep + 1}` as Route); };
return ( <div className="flex items-center justify-between gap-2 border-t pt-4"> {currentStep > 1 ? ( <Button type="button" variant="outline" data-testid="wizard-back" onClick={onBack} > Back </Button> ) : ( <span /> )} {currentStep < TOTAL_STEPS ? ( <Button type="button" data-testid="wizard-next" disabled={!isValid} onClick={onNext} > Next </Button> ) : ( <span /> )} </div> );};The gate. Next is disabled until the whole current slice parses. This is UX only — the action re-parses server-side next lesson, so this is defense against a confused user, not against a bypass.
'use client';
import type { Route } from 'next';import { useRouter } from 'next/navigation';import { useBroadcastRender } from '@/app/(app)/customers/new/_components/use-broadcast-render';import { useWizardStore } from '@/app/(app)/customers/new/_components/use-wizard-store';import { selectCurrentStep, selectIsStepValid,} from '@/app/(app)/customers/new/_lib/wizard/selectors';import { Button } from '@/components/ui/button';
const TOTAL_STEPS = 4;
export const WizardFooter = () => { const currentStep = useWizardStore(selectCurrentStep); const isValid = useWizardStore(selectIsStepValid); const goNext = useWizardStore((s) => s.goNext); const goBack = useWizardStore((s) => s.goBack); const router = useRouter();
// Report the footer's renders so the inspector's re-render-counter panel can // show it re-renders at most once per keystroke burst — only when the // Next-gate boolean (`isValid`) flips, not on every character typed. useBroadcastRender('footer');
const onBack = () => { goBack(); router.push(`/customers/new/step-${currentStep - 1}` as Route); };
const onNext = () => { goNext(); router.push(`/customers/new/step-${currentStep + 1}` as Route); };
return ( <div className="flex items-center justify-between gap-2 border-t pt-4"> {currentStep > 1 ? ( <Button type="button" variant="outline" data-testid="wizard-back" onClick={onBack} > Back </Button> ) : ( <span /> )} {currentStep < TOTAL_STEPS ? ( <Button type="button" data-testid="wizard-next" disabled={!isValid} onClick={onNext} > Next </Button> ) : ( <span /> )} </div> );};Next renders only while you are before the review. On step 4 there is no Next here — the review owns its own submit button, which lands next lesson.
The handler is the senior call worth dwelling on. The naive instinct is to call goNext() and let a useEffect watching currentStep fire the router.push “automatically.” That is effects-as-orchestrators, and it inverts cause and effect: the navigation isn’t a consequence of currentStep changing, it is the action the user asked for, so it belongs in the click handler next to goNext. Keep both in the one handler and the flow reads top to bottom; split them and you’ve hidden half the behavior in an effect that fires on a state change, which is exactly the indirection this course steers you away from.
Notice goNext is also why the progress pips fill in. Last lesson you wrote it to append the step you are leaving to completedSteps, de-duped, in the same set that increments currentStep. So a single click advances the step and records the trail, and the provided WizardProgress — already subscribed to both currentStep and completedSteps — lights the new current pip and marks the one you completed, with no extra wiring here.
For the safeParse and flattened-error semantics this gate runs on, see parse, safeParse, and the error contract, and for the canonical { ok, error } shape the action returns next lesson, Result or throw — the gate consumes a schema and a contract you’ve already met. The 'use client' directive on every file here, and the Server/Client boundary it draws, is Directives and server-only enforcement; this lesson applies that split rather than re-explaining it.
TkDodo's canonical guide to the atomic-selector default you build each field on, and why returning a new object re-renders.
The official escape hatch for when you genuinely need a multi-pick object selector instead of atomic reads.
The hook under Zustand, including the 'getSnapshot should be cached' loop that the whole-error-object subscription triggers.
The flattenError helper behind selectStepErrors, turning a parse failure into the fieldErrors map each field reads from.
Moment of truth
Section titled “Moment of truth”The selector logic is pure — selectIsStepValid and selectStepErrors are functions of store.getState(), no React render involved — so the lesson ships a real test suite over them. Run it with the project’s lesson runner:
pnpm test:lesson 3The suite builds a fresh store with createWizardStore(), fills a step’s slice through the setters you wired last lesson, positions currentStep, and asserts what the selectors return. It drives requirements 1 through 3: a filled slice reports valid and an empty or malformed one reports invalid at each step, the flattened error map keys a failing field by name with a message array (and omits fields that pass), and step 4 reports valid with an empty error map because there is no step-4 schema. When the selectors are wired correctly you’ll see it pass green:
✓ lesson-verification/Lesson 3.ts (10 tests)
Test Files 1 passed (1) Tests 10 passed (10)The tests reach the selectors but not the React behavior — the surgical re-renders, the URL advance, and the inline-error DOM are runtime concerns, and the inspector exists to confirm them by hand. Walk this list against /inspector and the browser:
step-N segment, advances currentStep, and the progress indicator highlights the new pip.useWizardStore((s) => s.contact) makes all four contact fields re-render on every keystroke; revert it.That last item is the one to actually perform, not just read — swap a single field’s value selector to useWizardStore((s) => s.contact), watch all four contact counters climb together per keystroke in the inspector, then revert. Seeing the broken version next to the working one is what makes the atomic-selector default stick.
With the suite green and those checks confirmed, every step writes into its slice, errors surface inline, and the Next-gate moves the wizard forward only on valid data. What’s missing is the end of the line: the step-4 review and the submit. That is the final lesson, where the three slices come together into one payload, a Server Action writes the customer, and the store resets behind a redirect.