ESC

Type to search the knowledge base.

Multi Step Wizard

Machine-coding brief for a multi-step wizard — step state, validation gates, linear/non-linear nav, and a11y.

intermediate4 min read
  • machine-coding
  • interview
  • react
  • forms

Problem statement

Build a multi-step wizard / stepper: N steps, each with its own form fields; Next/Back; final Submit. Interviewers score lifted form state, validation that gates Next, and whether step UI stays accessible (progress, focus).

Requirements

Must have

  • Ordered steps with labels
  • Show one step’s content at a time
  • Back / Next controls; Submit on last step
  • Keep field values when navigating away and back
  • Disable Next when current step invalid (or show errors on attempt)
  • Visible progress (stepper or “Step 2 of 4”)

Should have

  • Click step indicators to jump only if previous steps valid (or always allow back jumps)
  • Per-step schema validation
  • Dirty / isSubmitting states

Nice to have

  • URL sync (?step=2) for refresh resilience
  • Async validation
  • Save draft

Planning (5 minutes out loud)

  1. Single form store — all fields in parent; steps are views
  2. Step config — { id, title, fields, validate }
  3. Index vs id for current step
  4. MVP — 3 steps + shared state + gate Next; then stepper clicks + errors
  5. Don’t unmount-and-lose uncontrolled inputs

Architecture

Wizard
├── Stepper (indicators)
├── StepPanel (active step fields)
└── Footer (Back / Next / Submit)

Data model

type WizardData = {
  account: { email: string; name: string };
  plan: { tier: "free" | "pro" };
  confirm: { acceptTerms: boolean };
};

type StepId = keyof WizardData;

type StepConfig = {
  id: StepId;
  title: string;
  validate: (data: WizardData) => Partial<Record<string, string>>;
};

Implementation sketch

const steps: StepConfig[] = [
  {
    id: "account",
    title: "Account",
    validate: (d) => {
      const e: Record<string, string> = {};
      if (!d.account.email.includes("@")) e.email = "Valid email required";
      if (!d.account.name.trim()) e.name = "Name required";
      return e;
    },
  },
  {
    id: "plan",
    title: "Plan",
    validate: () => ({}),
  },
  {
    id: "confirm",
    title: "Confirm",
    validate: (d) =>
      d.confirm.acceptTerms ? {} : { acceptTerms: "Please accept terms" },
  },
];

function Wizard({ onSubmit }: { onSubmit: (data: WizardData) => void }) {
  const [data, setData] = useState<WizardData>({
    account: { email: "", name: "" },
    plan: { tier: "free" },
    confirm: { acceptTerms: false },
  });
  const [index, setIndex] = useState(0);
  const [errors, setErrors] = useState<Record<string, string>>({});
  const [tried, setTried] = useState(false);
  const headingRef = useRef<HTMLHeadingElement>(null);

  const step = steps[index];
  const isLast = index === steps.length - 1;

  useEffect(() => {
    headingRef.current?.focus();
  }, [index]);

  function patch<K extends StepId>(key: K, value: WizardData[K]) {
    setData((d) => ({ ...d, [key]: value }));
  }

  function goNext() {
    const e = steps[index].validate(data);
    setErrors(e);
    setTried(true);
    if (Object.keys(e).length) return;
    if (isLast) {
      onSubmit(data);
      return;
    }
    setTried(false);
    setErrors({});
    setIndex((i) => i + 1);
  }

  function goBack() {
    setTried(false);
    setErrors({});
    setIndex((i) => Math.max(0, i - 1));
  }

  return (
    <div className="wizard">
      <ol className="stepper" aria-label="Progress">
        {steps.map((s, i) => (
          <li key={s.id} aria-current={i === index ? "step" : undefined}>
            {s.title}
          </li>
        ))}
      </ol>

      <h2 ref={headingRef} tabIndex={-1}>
        {step.title}
      </h2>
      <p className="sr-only" aria-live="polite">
        Step {index + 1} of {steps.length}
      </p>

      {step.id === "account" && (
        <AccountFields
          value={data.account}
          errors={tried ? errors : {}}
          onChange={(account) => patch("account", account)}
        />
      )}
      {/* plan + confirm similarly */}

      <div className="footer">
        <button type="button" onClick={goBack} disabled={index === 0}>
          Back
        </button>
        <button type="button" onClick={goNext}>
          {isLast ? "Submit" : "Next"}
        </button>
      </div>
    </div>
  );
}

Field wiring

function AccountFields({
  value,
  onChange,
  errors,
}: {
  value: WizardData["account"];
  onChange: (v: WizardData["account"]) => void;
  errors: Record<string, string>;
}) {
  return (
    <div>
      <label htmlFor="email">Email</label>
      <input
        id="email"
        value={value.email}
        aria-invalid={!!errors.email}
        aria-describedby={errors.email ? "email-err" : undefined}
        onChange={(e) => onChange({ ...value, email: e.target.value })}
      />
      {errors.email && (
        <p id="email-err" role="alert">
          {errors.email}
        </p>
      )}
    </div>
  );
}

Accessibility essentials

  • Step list communicates progress (aria-current="step")
  • Move focus to step heading on change (don’t strand focus on removed Next from previous view)
  • Errors: aria-invalid + role="alert" or summarized error list
  • Disable only with explanation; prefer allowing click + show errors

Performance notes

  • Wizard state is tiny — no optimization drama
  • Lazy-mount heavy steps (payment widget) until first visit, but keep values once mounted or store outside

Footguns

  1. Uncontrolled inputs remount and wipe values
  2. Validating all steps only on submit — Next should gate
  3. Focus lost between steps
  4. Browser back fighting in-wizard Back without URL sync
  5. Submit double-clicks — guard isSubmitting

Interview out-loud answer

Wizard state is one object in the parent; steps are views with validate functions. Next runs the active step’s validator and only then advances; Back never destroys data. Progress uses a stepper with aria-current, and focus moves to the step heading. URL sync and async submit are extensions. I’d ship two fields per step rather than a design-system showcase.

Further reading