Frontend Handbook
10Built

Multi-Step Form

A wizard with per-step validation, back navigation, and a review step.

How it works

A long form as a wizard, without a form library: one set of answers split into steps, each checked before moving on, with steps that depend on earlier answers, a draft that survives a reload, a step in the address so the browser's Back button works, and a server that checks everything again. Fill it in, then try a reload, the Back button, switching the role, and the server's failure modes.

  • One form, many steps: one store holds every answer; a step is just which fields you see and which rules must pass to leave it.
  • Derive, don't store: errors, applicable steps and which steps can be opened are computed from the answers, so they can't go stale.
  • The address is the step: Next adds a history entry, corrections replace it, so Back, Forward, refresh and links work per step.
  • Same rules, both sides: the server re-runs the browser's schemas plus its own checks, and its errors land on the right step and field.
Read more

Where you'd use it

  • Applications, onboarding, checkout, insurance quotes, tax forms: anything too long for one screen.
  • Forms where later questions depend on earlier answers.
  • Long forms people fill in over several visits, or on flaky connections.
  • Before reaching for a form library: this is the engine those libraries hide.

How we built it

Every answer lives in one external store, grouped by step, as the user typed it (strings, booleans, lists). Fields read their slice with useField(path), built on useSyncExternalStore with an equality-checked selection, so a keystroke re-renders that field and the inspector, not the form. Next to the values, the store keeps what's known about them: visited fields, steps where Next was tried, server errors and the furthest step reached.

Rules are one Zod schema per step, in a module shared with the server. Errors are never stored: they're the schema's issues for the current values, shown only once a field was left, its step was tried, or the server reported one. One rule spans steps (the start date on Role can't be before the earliest legal start on Work authorization); it's reported on the later step, which then shows as needing attention.

Which steps apply is a function of the answers (Engineering or Design questions by role, Work authorization only when sponsorship is needed). A step can be opened if every step before it is valid and it isn't past the furthest one reached; that one rule drives the progress list, deep links and draft restore. Answers to steps that stop applying stay in memory for the visit but are never validated, saved or sent.

The step is ?step=… in the address, read with useSearchParams (inside a Suspense boundary) and changed with history.pushState; an address naming a step that can't be opened is corrected with replaceState, so it adds no history entry. Every step change focuses the step's heading ("Step 3 of 7: Experience"), and a failed Next focuses an error summary whose links focus each field.

The draft (applicable answers, step, furthest step) is saved to localStorage 400 ms after a change as { version: 2, … }, parsed with a types-only schema on load (an answer that breaks today's rules is kept and shown with its error), migrated from version 1, or discarded with a note when unreadable. The email check is debounced, aborts the previous request and ignores answers that aren't for the latest one; Next waits for it. Submit re-validates in the browser, posts only applicable steps, and maps the server's { step, path, message } issues back onto fields, opening the earliest step that has one.

Best practices

  • Do: Show an error once a field is left or Next is pressed, then update it as the user types.

    Avoid: Errors on the first keystroke, or only after submitting the whole form.

  • Do: Put the error next to its field and a summary at the top that links to each one; move focus to the summary.

    Avoid: A red border alone, or one message at the top that doesn't say which field.

  • Do: Derive conditional steps from the answers and leave non-applicable answers out of what's saved and sent.

    Avoid: Storing the list of steps, or sending hidden answers the user can no longer see.

  • Do: Keep the step in the address and use replace for corrections.

    Avoid: A step index in component state: refresh resets it and Back leaves the form.

  • Do: Share the rules with the server and still treat every request (and every saved draft) as untrusted.

    Avoid: Trusting the browser's validation, or JSON.parse straight from storage into the form.

  • Do: Debounce async field checks, cancel the previous request, and drop answers that aren't for the latest value.

    Avoid: Firing a request per keystroke and showing whichever answer arrives last.

Try it with the demo controls

Next on an empty step
Errors appear by each field, the summary at the top gets focus, and its links jump to each field.
Role and sponsorship
Engineering or Design adds its own step, Sales adds none, and needing sponsorship adds Work authorization; Step X of N and the progress list follow.
Browser Back / Forward, refresh
You move one step at a time with your answers kept; a refresh reopens the same step with a “Draft restored” note.
Email
Try taken@example.com: “Checking…”, then the error. Next waits for a pending check and won't skip it.
Fill with sample answers
Every step gets valid answers and Review opens; use Edit on a group, then “Back to review”.
On submit, the server
Rejects a field: the form opens Experience with the server's message on the company. Says email was used: back to Personal. Fails (500): everything stays, with Retry.
Server latency
Slow it down to see “Checking…” and “Sending…”; pressing Submit again does nothing while it's sending.
Load a v1 draft / a corrupted draft
An old-format draft is converted (a Design applicant with two jobs); a corrupted one is discarded with a note instead of crashing.
Form inspector
Errors from the rules exist from the start; watch Visited and Tried decide when they're shown.

Trade-offs and limits

  • A form library would give you this (and more field types) for less code; the hand-built engine is here to show what it does.
  • The draft lives in one browser; two tabs editing at once overwrite each other, and files can't be saved in a draft at all.
  • Answers for steps that stopped applying are gone after a reload, by design.
  • Month rules use the month the visit started; a form left open across midnight on the last day of a month keeps the old "today".
  • No JavaScript, no wizard: a real app might fall back to a long single-page form.

Demo controls

Form inspector

Live state of the form engine. Errors exist from the first keystroke; a field only shows its error once it was visited or its step was tried.

Steps
{
  "applicable": [
    "Personal details",
    "Role",
    "Experience",
    "Cover letter and salary",
    "Review"
  ],
  "furthestReached": "Personal details",
  "tried": []
}
Requests
{
  "emailCheck": {
    "email": "",
    "status": "idle"
  },
  "submit": {
    "status": "idle"
  }
}
Values
{
  "personal": {
    "fullName": "",
    "email": "",
    "phone": "",
    "country": ""
  },
  "role": {
    "role": "",
    "employment": "",
    "startMonth": "",
    "needsSponsorship": ""
  },
  "experience": {
    "none": false,
    "entries": []
  },
  "engineering": {
    "languages": [],
    "years": "",
    "profileUrl": ""
  },
  "design": {
    "portfolioUrl": "",
    "tools": []
  },
  "authorization": {
    "residence": "",
    "status": "",
    "earliestMonth": ""
  },
  "extras": {
    "coverLetter": "",
    "salaryMin": "",
    "salaryMax": "",
    "source": ""
  },
  "review": {
    "consent": false
  }
}
Errors from the rules (12)
{
  "personal.fullName": "Your full name must be at least 2 characters",
  "personal.email": "Enter your email address",
  "personal.country": "Choose the country you live in",
  "role.role": "Choose the role you're applying for",
  "role.employment": "Choose the type of employment",
  "role.startMonth": "Choose the month and year you'd like to start",
  "role.needsSponsorship": "Tell us whether you need visa sponsorship",
  "experience.entries": "Add at least one job, or tick “I don't have work experience yet”",
  "extras.coverLetter": "Write at least 100 characters",
  "extras.salaryMin": "Enter a yearly amount from 10,000 to 1,000,000",
  "extras.salaryMax": "Enter a yearly amount from 10,000 to 1,000,000",
  "review.consent": "Confirm that your answers are accurate"
}
Visited fields (0)
[]
Server errors (0)
{}