Build a multi-step wizard that never loses what the user typed
By the end of this recipe you'll have a four-step wizard — type, dates, cover, review — with persistent progress, per-step validation, a branching path, "save & resume later", and a guarantee that going back never wipes a field.
Overview
A four-step flow: type → dates → cover → review. One reducer drives the whole machine; the back button is just step − 1 with the form data still in scope.
Wizards exist for one reason: the form is long enough that a single scroll-down page feels intimidating, and short enough that you can't justify a multi-page route. The staff leave-request flow is the canonical SMB example — type, dates, cover, notes — where every field has a reason to be there and every step has a reason to be its own screen. Branching matters too: if the request is a half-day, the cover step is skipped entirely.
The opinionated bit is that the wizard owns one formData object from step one, and every step reads/writes into the same shape. Going back never unmounts the data; it just rewinds the visible step. "Save & resume later" is a single localStorage write of that same object, keyed by user.
Skip this recipe if your form fits in fewer than six fields. Use a single Form with sectioned Stacks — a wizard for a six-field form is friction theatre.
What you'll build
Three of the four screens. The fourth — review — is just a read-only render of the same formData.
What kind of leave?
Step 1 of 4
When?
Step 2 of 4
Review & submit
Step 4 of 4
You can see this live in
Portal demos that wire this wizard in context. Open one to walk the steps end to end.
Required pieces
Everything this recipe pulls from @corelithzw/react.
@corelithzw/react exports used here: Stack, Form, Field, Input, Select, DatePicker, Button, Alert, Stepper. Keep the names — Phase 2 recipes copy them.
Step-by-step build
Model the wizard as { step, data, errors } — one reducer for everything
The mistake is one component per step with its own local useState. Then "back" is a navigation event and your data lives in three places. Instead, hoist a single reducer up to the wizard: every step is a pure projection of state.data, and NEXT / BACK just slide state.step.
.ph-stepper inline. The reducer below pairs with the new p-stepper primitive — three step states (done / current / pending), two layouts (compact pill row, labelled). Pass state.step as current and the primitive handles the visuals — your wizard only owns the reducer.
half-day = true: step 2 jumps to step 4 (cover irrelevant).
PATCH never clears the step. BACK never clears data.
Switch to the Code tab to see the snippet.
Validate per step, not per field — and never on every keystroke
Run each step's validator only when the user taps "Next". Live validation on keystroke is jumpy, especially for date ranges where "from" and "to" are mutually dependent. The validator returns a partial Errors map — empty means the step passes and the reducer advances.
Empty map → reducer advances. Non-empty → reducer stays put and writes errors.
No keystroke validation. Errors render below the field, not in a banner.
Switch to the Code tab to see the snippet.
Render the current step — and surface the stepper so the user can JUMP back
The Stepper primitive is more than decoration. Clicking a previous dot dispatches JUMP, which is the cheapest way to give "let me change my answer on step 1" without throwing away steps 2 and 3. You don't let the user jump forward past an unfilled step — that's what "Next" is for.
What kind of leave?
Persist the draft to localStorage so "Save & resume later" is one button
Wire a useEffect that writes state.data to localStorage on every change. "Save & resume later" is then just a close button — the draft is already there. On mount, read it back and seed the reducer.
"Save & resume later" = just close the modal. Draft survives.
On successful submit, call clearDraft() so the next request starts fresh.
Switch to the Code tab to see the snippet.
Submit from the review step — and roll back to the failed field if the API rejects
The review step is the only place that submits. On API failure, map the server's field errors back into errors and JUMP the user to the offending step. Don't show the failure as a generic banner on the review screen — that leaves the user guessing which field to fix.
Field-to-step map is the only "magic" — keep it next to the reducer.
Generic 500? Stay on review and show an Alert tone="danger".
Switch to the Code tab to see the snippet.
Final composition
The whole LeaveWizard, assembled. Reducer, branch logic, draft persistence, per-step validation, error-recovery routing.
Review & submit
Variations
Three common forks. Each is a small diff from the final composition.
Linear (no branch)
Every step always runs. Drop the half-day jump so step 2 always advances to step 3.
// In reducer NEXT:
case 'NEXT':
if (a.errors && Object.keys(a.errors).length)
return { ...s, errors: a.errors };
return { ...s, step: (s.step + 1) as Step, errors: {} };
// drop the (s.step === 2 && halfDay) branch
Skip review
Power users tap "Submit" on step 3 directly. Add a submitOnLast branch that fires the submit handler when the last data step completes.
const isLastDataStep = s.step === 3;
const submitText = isLastDataStep
? 'Submit request'
: 'Next';
<Button type="submit">{submitText}</Button>
// drop the {s.step === 4} review block
// keep validators 1–3 unchanged
Server-driven steps
Steps come back from the API (e.g. KYC). Replace the static STEPS array with a fetched list and key validators by step name, not number.
const [steps, setSteps] = useState<StepDef[]>([]);
useEffect(() => {
fetch('/api/kyc/steps')
.then(r => r.json())
.then(setSteps);
}, []);
// validators[steps[s.step - 1].name](s.data)
Accessibility
What this recipe takes care of for you.
- Stepper announces progress.
Stepperrenders anaria-label="Step 2 of 4: Dates"on the current dot, so screen-reader users hear where they are when the step changes. - Focus jumps to the new step's heading. When
stepchanges, the wizard moves focus to the step's<h2>. Tabbing then lands on the first field. - Errors are field-linked, not banner-only.
Field error={…}renders anaria-describedbymessage under the input — screen readers read field + error together. - Back is a real button. Not a link, not
onClickon a div. Keyboard users hit Shift+Tab to it and Enter to fire. - Submit is
type="submit". Enter inside any field on the review step submits the wizard, same as tapping the button. - The draft never silently overwrites. If a draft is restored, the wizard renders an
Alert tone="info": "Resumed your last draft from 2 days ago." Users can dismiss without losing data.