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)
- Single form store — all fields in parent; steps are views
- Step config —
{ id, title, fields, validate } - Index vs id for current step
- MVP — 3 steps + shared state + gate Next; then stepper clicks + errors
- 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
- Uncontrolled inputs remount and wipe values
- Validating all steps only on submit — Next should gate
- Focus lost between steps
- Browser back fighting in-wizard Back without URL sync
- 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.