Skip to content
Advanced90 min build target7 min guide

Multi-step form

A wizard-style form that validates per step, preserves state across steps, and supports going back.

Multi-step form interface reference

HOW TO USE THIS CHALLENGE

  1. 1. Read the briefClarify decisions before coding.
  2. 2. Build from memoryUse the 90-minute target.
  3. 3. Study the guideCompare architecture, tests, and trade-offs.

REQUIREMENTS

  • Break a long form into sequential steps with a visible progress indicator.
  • Validate the current step before allowing 'Next'; show inline field errors.
  • Preserve all previously entered data when navigating back and forth between steps.
  • Support jumping directly to a previously completed step via the progress indicator.
  • Submit the full, combined data only from the final step.

EDGE CASES

  • Going back to fix a field shouldn't clear data entered on later steps.
  • A required field left empty should block only that step's progression, with focus moved to the first invalid field.
  • Refreshing mid-wizard: decide and implement whether progress persists (e.g. via sessionStorage) or intentionally resets, and make that behavior explicit.
  • Submitting should be disabled (or debounced) to prevent duplicate submissions from a double-click.

ACCESSIBILITY

  • Announce step changes (e.g. 'Step 2 of 4: Shipping details') via a live region or by moving focus to the new step's heading.
  • Each step's fields keep proper label associations and error messages linked via aria-describedby.
  • The step indicator, if clickable, exposes current/completed/upcoming state via aria-current or equivalent, and is keyboard-operable.

SUGGESTED APPROACH

  • Hold all form data in one object at the wizard level (or a form-library instance scoped to the whole flow), not separate state per step, so nothing is lost switching steps.
  • Define each step's required fields and a pure validate(step, data) function, run before allowing navigation forward.
  • Render only the active step's fields, keyed by step id, while keeping the shared data object as the single source of truth.
  • Move focus to the step heading (or first invalid field on a failed validation) after every step transition for both usability and accessibility.

EVALUATION RUBRIC

  • Data survives backward/forward navigation between all steps without loss.
  • Per-step validation blocks progression correctly and surfaces clear, associated error messages.
  • Final submission only fires once, from the last step, with the complete combined data.
  • Step transitions manage focus deliberately rather than leaving it stranded.

01Understand the product before coding

Learning goals

  • Break a long form into sequential steps with a visible progress indicator.
  • Validate the current step before allowing 'Next'; show inline field errors.
  • Preserve all previously entered data when navigating back and forth between steps.
  • Support jumping directly to a previously completed step via the progress indicator.

Decisions to state aloud

  • Going back to fix a field shouldn't clear data entered on later steps.
  • A required field left empty should block only that step's progression, with focus moved to the first invalid field.
  • Refreshing mid-wizard: decide and implement whether progress persists (e.g. via sessionStorage) or intentionally resets, and make that behavior explicit.
  • Submitting should be disabled (or debounced) to prevent duplicate submissions from a double-click.

02State model and invariants

One wizard state owns all values, touched fields, errors, current step, completed steps, and submission status. Step definitions list fields and validation; current visibility is derived. A discriminated submit state prevents simultaneous submitting and success UI.

TypeScript
type SubmitState = { status: 'idle' } | { status: 'submitting' } |  { status: 'error'; message: string } | { status: 'success'; orderId: string };type WizardState = {  values: FormValues; touched: Set<keyof FormValues>;  errors: Partial<Record<keyof FormValues, string>>;  stepId: StepId; completed: Set<StepId>; submit: SubmitState;};

03Component architecture

  1. 01Wizard reducer owns cross-step data and transitions.
  2. 02StepRegistry defines fields, labels, validation, and next-step logic.
  3. 03StepIndicator exposes current and completed navigation.
  4. 04Field components connect label, hint, error, and value.
  5. 05Submission boundary maps server field and form errors.

04Reference implementation walkthrough

Step 1

Define steps as data

Give every step a stable id, field list, and pure validator. Conditional branching chooses the next id from current values rather than relying on numeric indexes.

TypeScript
const steps: Record<StepId, Step> = {  account: { fields: ['email'], next: () => 'shipping' },  shipping: { fields: ['address', 'country'], next: (v) => v.country === 'US' ? 'tax' : 'review' },  tax: { fields: ['taxId'], next: () => 'review' },  review: { fields: [], next: () => null },};

Step 2

Validate one transition

Next marks current fields touched, computes errors, blocks and focuses the first invalid field, or marks the step completed and moves focus to the next heading.

TSX
const errors = validateStep(stepId, state.values);if (Object.keys(errors).length) {  dispatch({ type: 'validationFailed', errors });  requestAnimationFrame(() => focusField(Object.keys(errors)[0]));  return;}dispatch({ type: 'advanced', to: steps[stepId].next(state.values)! });

Step 3

Persist deliberately

Version session drafts, store only necessary non-sensitive values, migrate or discard incompatible versions, and never persist payment secrets. Completion clears the draft.

Step 4

Make final submission idempotent

Guard duplicate activation in the client and use an idempotency key on the server. Map authoritative field errors back to their owning step and focus an error summary.

SOLComplete solution, explained simply

Build it yourself first. This is one correct implementation, not the only one — read it top to bottom, then compare the shape of your version.

We are building a checkout wizard where the path can branch (US buyers get a tax step, others skip it), your data is never lost when a step unmounts, each step is validated only when you try to leave it, and submitting twice cannot create two orders. The one big idea: describe the steps as data — each step lists its fields, its own validator, and a function that decides the next step from the current answers — and keep every value in one shared state object.

Wizard.tsx
import { useReducer, useRef } from "react";
type Values = { email: string; address: string; country: string; taxId: string };type StepId = "account" | "shipping" | "tax" | "review";type Errors = Partial<Record<keyof Values, string>>;type Step = {  title: string;  fields: (keyof Values)[];  validate: (v: Values) => Errors;  next: (v: Values) => StepId | null; // null means 'this is the last step'};
const steps: Record<StepId, Step> = {  account: {    title: "Account",    fields: ["email"],    validate: (v) => (/^[^@\s]+@[^@\s]+$/.test(v.email) ? {} : { email: "Enter a valid email" }),    next: () => "shipping",  },  shipping: {    title: "Shipping",    fields: ["address", "country"],    validate: (v) => ({      ...(v.address ? {} : { address: "Address is required" }),      ...(v.country ? {} : { country: "Country is required" }),    }),    next: (v) => (v.country === "US" ? "tax" : "review"),  },  tax: {    title: "Tax",    fields: ["taxId"],    validate: (v) => (v.taxId ? {} : { taxId: "Tax ID is required in the US" }),    next: () => "review",  },  review: { title: "Review", fields: [], validate: () => ({}), next: () => null },};
type Submit =  | { status: "idle" } | { status: "submitting" }  | { status: "error"; message: string } | { status: "success"; orderId: string };type State = { values: Values; errors: Errors; stepId: StepId; history: StepId[]; submit: Submit };type Action =  | { type: "change"; field: keyof Values; value: string }  | { type: "invalid"; errors: Errors }  | { type: "advance"; to: StepId }  | { type: "back" }  | { type: "submit"; state: Submit };
function reducer(state: State, action: Action): State {  switch (action.type) {    case "change":      return {        ...state,        values: { ...state.values, [action.field]: action.value },        errors: { ...state.errors, [action.field]: undefined },      };    case "invalid":      return { ...state, errors: action.errors };    case "advance":      return { ...state, stepId: action.to, history: [...state.history, state.stepId], errors: {} };    case "back": {      const history = [...state.history];      const prev = history.pop();      return prev ? { ...state, stepId: prev, history, errors: {} } : state;    }    case "submit":      return { ...state, submit: action.state };  }}
export function Wizard({ placeOrder }: {  placeOrder: (v: Values, idempotencyKey: string) => Promise<{ orderId: string }>;}) {  const [state, dispatch] = useReducer(reducer, {    values: { email: "", address: "", country: "", taxId: "" },    errors: {}, stepId: "account", history: [], submit: { status: "idle" },  });  const step = steps[state.stepId];  const idempotencyKey = useRef(crypto.randomUUID()).current; // same key for every retry
  function focusFirstError(errors: Errors) {    const first = Object.keys(errors)[0];    if (first) requestAnimationFrame(() =>      document.querySelector<HTMLElement>('[name="' + first + '"]')?.focus(),    );  }
  async function submit() {    if (state.submit.status === "submitting") return; // client-side double-click guard    dispatch({ type: "submit", state: { status: "submitting" } });    try {      const { orderId } = await placeOrder(state.values, idempotencyKey);      dispatch({ type: "submit", state: { status: "success", orderId } });    } catch {      dispatch({ type: "submit", state: { status: "error", message: "Could not place the order." } });    }  }
  function onNext() {    const errors = step.validate(state.values);    if (Object.keys(errors).length > 0) {      dispatch({ type: "invalid", errors });      focusFirstError(errors);      return;    }    const to = step.next(state.values);    if (to) dispatch({ type: "advance", to });    else submit();  }
  if (state.submit.status === "success") {    return <p role="status">Order {state.submit.orderId} placed.</p>;  }
  return (    <form onSubmit={(e) => { e.preventDefault(); onNext(); }}>      <ol aria-label="Progress">        {(Object.keys(steps) as StepId[]).map((id) => (          <li key={id} aria-current={id === state.stepId ? "step" : undefined}>{steps[id].title}</li>        ))}      </ol>
      <h2>{step.title}</h2>
      {step.fields.map((field) => (        <label key={field}>          {field}          <input            name={field}            value={state.values[field]}            aria-invalid={Boolean(state.errors[field])}            onChange={(e) => dispatch({ type: "change", field, value: e.target.value })}          />          {state.errors[field] && <span role="alert">{state.errors[field]}</span>}        </label>      ))}
      {state.submit.status === "error" && <p role="alert">{state.submit.message}</p>}
      <button type="button" disabled={state.history.length === 0} onClick={() => dispatch({ type: "back" })}>        Back      </button>      <button type="submit" disabled={state.submit.status === "submitting"}>        {step.next(state.values) === null ? "Place order" : "Next"}      </button>    </form>  );}

How each part works

Steps are data, not components

The steps object maps each step id to its title, its fields, a pure validate function, and a next function. Nothing about the flow is hard-coded in JSX. Adding, removing, or reordering a step is a data edit, and the form renders whatever the current step's fields list says.

Branching uses next(values), not a number

shipping.next returns 'tax' only when country is US, otherwise 'review'. Because the path is computed from answers, a numeric 'step 3' index would be meaningless — someone in France and someone in the US are on different steps at the same point. history is a stack of the ids actually visited, so Back always returns to the real previous step.

One shared values object

Every field for every step lives in state.values from the start. When a step unmounts, its data is untouched. Cross-step rules (like 'tax id only if US') can read every answer, and the final submit already has the complete object with nothing to reassemble.

Validation happens on the transition

onNext calls the current step's validate. If it returns any errors, we store them, move focus to the first invalid field, and stop — we do not advance. Only a clean validation lets step.next run. Typing in a field clears just that field's error.

Submit is guarded twice

The client check returns early if status is already 'submitting', so a fast double-click does nothing. The idempotencyKey — generated once and reused for every retry — is sent to the server, which uses it to recognize a repeat and return the same order instead of creating a second one. Client guards alone cannot guarantee this.

A discriminated submit state

submit is exactly one of idle, submitting, error, or success. The button is disabled while submitting, the error shows only in the error state, and the whole form is replaced by a confirmation in the success state. There is no way to show 'submitting' and 'success' at once.

Why this is correct

  • Describing steps as data (fields, validator, next function) makes a branching flow a data structure instead of tangled conditional JSX.
  • The next step is computed from the answers, so stable step ids and a visited-history stack replace numeric indexes.
  • All values live in one object for the whole flow, so unmounting a step never loses data and cross-step rules see everything.
  • Validate a step only when the user tries to leave it, then block and focus the first invalid field.
  • Prevent duplicate orders with a client guard for responsiveness plus a server idempotency key for the real guarantee.

05Testing strategy

Critical behavior

  • Data survives backward/forward navigation between all steps without loss.
  • Per-step validation blocks progression correctly and surfaces clear, associated error messages.
  • Final submission only fires once, from the last step, with the complete combined data.
  • Step transitions manage focus deliberately rather than leaving it stranded.

Failure and boundary cases

  • Going back to fix a field shouldn't clear data entered on later steps.
  • A required field left empty should block only that step's progression, with focus moved to the first invalid field.
  • Refreshing mid-wizard: decide and implement whether progress persists (e.g. via sessionStorage) or intentionally resets, and make that behavior explicit.
  • Submitting should be disabled (or debounced) to prevent duplicate submissions from a double-click.

Accessibility

  • Announce step changes (e.g. 'Step 2 of 4: Shipping details') via a live region or by moving focus to the new step's heading.
  • Each step's fields keep proper label associations and error messages linked via aria-describedby.
  • The step indicator, if clickable, exposes current/completed/upcoming state via aria-current or equivalent, and is keyboard-operable.

06Performance and production hardening

  • Keep validation pure and scoped to relevant fields.
  • Avoid rerendering every field on one keystroke through granular subscriptions where measured.
  • Lazy-load genuinely heavy optional steps.
  • Persist drafts with a bounded debounce, not every character synchronously.

QAInterview questions and model answers

Answer aloud first. Then open the model answer and compare state ownership, failure handling, accessibility, and trade-offs—not exact wording.

Model answer

Unmounted steps cannot lose values, cross-step validation sees one source of truth, and final submission uses an already combined model.

Primary references

Ready to build it?

Implement the brief above in your own environment against a timer close to 90 minutes, then self-review against the rubric before moving on.

Back to all briefs →