Home/ Cookbook/ Forms/ Multi-step wizard

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.

Forms 5 primitives · 0 blocks · 1 pattern ~35 min React · @corelithzw/react

Overview

A four-step flow: typedatescoverreview. 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.

Huchu · Leave

What kind of leave?

Step 1 of 4

Next: Dates
01 Type — branching answer
Huchu · Leave

When?

Step 2 of 4

Back
Next: Cover
02 Dates — back keeps everything
Huchu · Leave

Review & submit

Step 4 of 4

TypeAnnual leave
Dates25–27 Jun · 3 days
CoverAnesu Sibanda
Submit request
03 Review — last chance to tap "Back"

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

01

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.

Use the Stepper primitive. Earlier drafts of this recipe rolled a custom .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.
Code-only step

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.

Step 1 · Reducer@corelithzw/react
02

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.

Code-only step

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.

Step 2 · Validation
03

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?

Leave type
Annual leave
Back
Next: Dates
Step 3 · Render step + stepper
04

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.

Code-only step

"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.

Step 4 · Draft persistence
05

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.

Code-only step

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.

Step 5 · Submit + recovery

Final composition

The whole LeaveWizard, assembled. Reducer, branch logic, draft persistence, per-step validation, error-recovery routing.

Review & submit

Type
Annual leave
Dates
25 Jun → 27 Jun · 3 days
Cover
Anesu Sibanda
Back
Submit request
LeaveWizard.tsx@corelithzw/react

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. Stepper renders an aria-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 step changes, 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 an aria-describedby message under the input — screen readers read field + error together.
  • Back is a real button. Not a link, not onClick on 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.