Skip to content
Chapter 79Lesson 3

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.

Step 1 with three valid fields and an empty phone — the inline path is clear and Next stays disabled until the slice parses.

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.

Running a step’s schema over a fully valid slice reports the step valid; an empty or partially filled slice reports it invalid.
tested
An invalid field surfaces its message through the step’s field-error map, and a slice that parses yields no errors.
tested
On the review step there is no schema, so the gate reports valid and never blocks the review.
tested
Typing in a step-1/2/3 field persists its value in the slice — leaving the step and returning shows the typed value.
untested
An invalid value renders an inline error under its field (for example “Invalid email”); a valid value clears it.
untested
Next is disabled while any field on the current step is invalid or empty, and enables only when the whole current slice parses.
untested
Clicking Next advances both the URL to the next segment and the store’s current step, and the progress indicator highlights the new pip; Back returns with that step’s prior data intact.
untested
Typing ten characters into one field increments only that field’s render counter by ten, leaves its siblings flat, and re-renders the footer at most once — when validity flips.
untested

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

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:

src/app/(app)/customers/new/_lib/wizard/selectors.ts
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.

1 / 1

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:

src/app/(app)/customers/new/step-1/page.tsx
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.

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.

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:

src/app/(app)/customers/new/step-2/page.tsx
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 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:

src/app/(app)/customers/new/step-3/page.tsx
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.

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.

1 / 1

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.

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:

Terminal window
pnpm test:lesson 3

The 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:

Typing in a step-1/2/3 field writes into its slice — the inspector snapshot updates — and the value persists across leaving and returning to the step.
untested
An invalid value renders an inline error under its field (for example “Invalid email”); a valid value clears it; Next stays disabled until the whole current slice parses.
untested
Clicking Next pushes the URL to the next step-N segment, advances currentStep, and the progress indicator highlights the new pip.
untested
Clicking Back returns to the prior segment with that step’s previously typed data still populated.
untested
On the re-render counter, focusing step-1 first name and typing ten characters increments only first name’s counter by ten, leaves the siblings flat, and re-renders the footer at most once.
untested
Temporarily changing one field’s selector to a whole-slice read useWizardStore((s) => s.contact) makes all four contact fields re-render on every keystroke; revert it.
untested

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.