Machine-Coding Round: Build a Multi-Step Form with Validation in React
In front-end engineering interviews—especially at companies like Uber, Stripe, Atlassian, and Airbnb—the machine-coding round tests your ability to translate ambiguous product requirements into production-ready, accessible, and maintainable UI components within a strict 45-to-60-minute time limit. Building a multi-step form wizard is one of the most frequently tested challenges because it assesses state management, step validation boundaries, keyboard accessibility, and user feedback under time pressure.
Interviewers evaluate whether candidates:
- Architect Clean State Boundaries: Coordinate multi-step data without losing progress across navigation transitions.
- Implement Resilient Field Validation: Validate only the active step before allowing users to advance, avoiding premature or missed error states.
- Ensure Full Accessibility: Implement accessible progress indicators, associate error messages via
aria-describedby, and announce step changes viaaria-live. - Deliver Under Time Pressure: Manage the 45-minute budget systematically by building working primitives first before adding styling or animations.
This guide provides an end-to-end walkthrough for building an accessible multi-step form wizard in React, complete with time allocations, architecture decisions, production code, edge case handling, and automated tests.
Milestone Index
| Milestone | Focus Area | Target Duration |
|---|---|---|
| 1. Requirements & Clarifying Questions | Scope boundaries, validation rules, step transitions | 00:00–00:07 |
| 2. Time Budget & State Plan | Data model, active step index, error schemas | 00:07–00:15 |
| 3. Step-by-Step Implementation | Form container, inputs, progress bar, review screen | 00:15–00:35 |
| 4. Full Production Implementation | Modular TypeScript component with lifted state | 00:35–00:45 |
| 5. Accessibility & Edge Cases | Screen reader announcements, keyboard navigation | 00:45–00:50 |
| 6. Testing & Interview Extensions | React Testing Library suite, draft saving | 00:50–00:60 |
1. Requirements and Clarifying Questions
Before writing a single line of React code in a machine-coding interview, spend 5 to 7 minutes agreeing on functional and accessibility boundaries with the interviewer. Clarifying assumptions upfront demonstrates senior engineering rigor and prevents costly mid-session refactors.
Functional Requirements
- Step Progression: The wizard must contain at least three distinct steps: Personal Info (name, email), Account Setup (username, password), and Review & Submit (data confirmation).
- Step-by-Step Validation: The "Next" button must only advance if all required fields in the current step pass validation. Prevent advancing with empty or invalid fields.
- State Preservation: Moving backwards with "Previous" must preserve all previously entered values across all steps without re-prompting.
- Progress Indicator: A visible step tracker showing current progress (e.g., "Step 2 of 3" with step titles).
- Final Submission: The final step renders a summary review. Submitting the form triggers a mock asynchronous submit handler displaying a success confirmation.
Non-Functional & Accessibility Requirements
- Keyboard Operability: All inputs, navigation buttons, and checkboxes must be fully operable via keyboard (
Tab,Shift+Tab,Enter,Space). - Accessible Error Messaging: Error messages must link to their corresponding inputs using
aria-describedbyandaria-invalid="true". - Live Region Announcements: Screen readers must announce step transitions using an
aria-live="polite"status message. - Clean DOM Structure: Use native
<form>elements and semantic headings (<h1>,<h2>,<fieldset>,<legend>).
Clarifying Questions to Ask the Interviewer
- Interviewer: "Can the user click on arbitrary step indicators to jump ahead?"
- Candidate Question: "Should users only be allowed to navigate to completed steps, or must they strictly advance sequentially using the Next button?" (Standard response: Strict sequential forward progression; free backward navigation to completed steps).
- Candidate Question: "Do we need asynchronous validation (like checking username availability over an API) or client-side schema checks?" (Standard response: Start with synchronous regex/length validation; discuss async checks as an extension).
- Candidate Question: "Should form progress be preserved in
localStorageon page refresh?" (Standard response: Keep in memory first; implement storage as an extension if time permits).
2. Time Budget and State Plan
45-Minute Interview Time Budget
- Minutes 0–5: Clarify requirements, define steps, sketch state shape on whiteboard/comments.
- Minutes 5–15: Build the core state container and per-step validation logic.
- Minutes 15–30: Render step forms, progress indicator, and Next/Previous navigation buttons.
- Minutes 30–38: Build the review summary screen and mock submission loading state.
- Minutes 38–45: Implement accessibility attributes (
aria-describedby,aria-live), test edge cases, and run RTL tests.
Component State & Props Contract
A common anti-pattern in multi-step forms is storing form state locally inside each step component, which causes data loss when child components unmount. State must be lifted to a parent container or managed via a unified reducer.
export interface FormData {
// Step 1: Personal Info
fullName: string;
email: string;
// Step 2: Account Details
username: string;
accountType: 'personal' | 'business';
subscribeNewsletter: boolean;
}
export interface FormErrors {
fullName?: string;
email?: string;
username?: string;
accountType?: string;
}
export interface StepConfig {
id: number;
title: string;
description: string;
}
State and Ref Plan
currentStep: Integer index (0,1,2) tracking the active step.formData: Unified object holding all fields across all steps.errors: Map of field validation error strings for the currently active step.isSubmitting: Boolean tracking async submission state to disable action buttons.isSubmitted: Boolean to toggle the final success banner.
3. Step-by-Step Implementation
Step 1: Form State Container and Step Context
Create the unified state container and validation helpers:
import React, { useState } from 'react';
export const INITIAL_DATA: FormData = {
fullName: '',
email: '',
username: '',
accountType: 'personal',
subscribeNewsletter: false,
};
export function validateStep(step: number, data: FormData): FormErrors {
const errors: FormErrors = {};
if (step === 0) {
if (!data.fullName.trim()) {
errors.fullName = 'Full name is required.';
} else if (data.fullName.trim().length < 2) {
errors.fullName = 'Name must be at least 2 characters.';
}
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!data.email.trim()) {
errors.email = 'Email address is required.';
} else if (!emailRegex.test(data.email)) {
errors.email = 'Please provide a valid email address.';
}
}
if (step === 1) {
if (!data.username.trim()) {
errors.username = 'Username is required.';
} else if (data.username.trim().length < 3) {
errors.username = 'Username must be at least 3 characters.';
}
}
return errors;
}
Step 2: Per-Step Field Inputs and Live Validation
Each step renders accessible inputs with validation feedback:
export function StepPersonalInfo({
data,
errors,
onChange,
}: {
data: FormData;
errors: FormErrors;
onChange: (field: keyof FormData, value: string | boolean) => void;
}) {
return (
<fieldset>
<legend>Personal Information</legend>
<div className="field">
<label htmlFor="fullName">Full Name</label>
<input
id="fullName"
type="text"
value={data.fullName}
onChange={(e) => onChange('fullName', e.target.value)}
aria-invalid={Boolean(errors.fullName)}
aria-describedby={errors.fullName ? 'fullName-error' : undefined}
/>
{errors.fullName && (
<span id="fullName-error" className="error-text" role="alert">
{errors.fullName}
</span>
)}
</div>
<div className="field">
<label htmlFor="email">Email Address</label>
<input
id="email"
type="email"
value={data.email}
onChange={(e) => onChange('email', e.target.value)}
aria-invalid={Boolean(errors.email)}
aria-describedby={errors.email ? 'email-error' : undefined}
/>
{errors.email && (
<span id="email-error" className="error-text" role="alert">
{errors.email}
</span>
)}
</div>
</fieldset>
);
}
Step 3: Navigation Controls and Step Progress Indicator
Implement the accessible progress bar and step transition buttons:
export function ProgressBar({ currentStep, totalSteps, steps }: {
currentStep: number;
totalSteps: number;
steps: StepConfig[];
}) {
const percentage = Math.round(((currentStep + 1) / totalSteps) * 100);
return (
<div className="progress-container">
<div
role="progressbar"
aria-valuenow={percentage}
aria-valuemin={0}
aria-valuemax={100}
aria-label="Form completion progress"
className="progress-bar-track"
>
<div className="progress-bar-fill" style={{ width: `${percentage}%` }} />
</div>
<ol className="step-list">
{steps.map((step, idx) => (
<li
key={step.id}
className={`step-item ${idx === currentStep ? 'active' : ''} ${idx < currentStep ? 'completed' : ''}`}
aria-current={idx === currentStep ? 'step' : undefined}
>
<span>{step.title}</span>
</li>
))}
</ol>
</div>
);
}
Step 4: Review Screen and Form Submission
Render the summary review screen and handle async submission:
export function StepReview({ data }: { data: FormData }) {
return (
<div className="review-container">
<h3>Review Your Details</h3>
<dl>
<dt>Full Name:</dt>
<dd>{data.fullName}</dd>
<dt>Email:</dt>
<dd>{data.email}</dd>
<dt>Username:</dt>
<dd>{data.username}</dd>
<dt>Account Type:</dt>
<dd>{data.accountType}</dd>
<dt>Newsletter:</dt>
<dd>{data.subscribeNewsletter ? 'Subscribed' : 'No'}</dd>
</dl>
</div>
);
}
4. Full Production Implementation
Here is the complete, self-contained multi-step form wizard component ready for production or interview evaluation:
import React, { useState } from 'react';
export interface FormData {
fullName: string;
email: string;
username: string;
accountType: 'personal' | 'business';
subscribeNewsletter: boolean;
}
export interface FormErrors {
fullName?: string;
email?: string;
username?: string;
}
const STEPS = [
{ id: 0, title: 'Personal Info' },
{ id: 1, title: 'Account Setup' },
{ id: 2, title: 'Review & Submit' },
];
export function MultiStepFormWizard({
onSubmitSuccess,
}: {
onSubmitSuccess?: (data: FormData) => void;
}) {
const [currentStep, setCurrentStep] = useState(0);
const [formData, setFormData] = useState<FormData>({
fullName: '',
email: '',
username: '',
accountType: 'personal',
subscribeNewsletter: false,
});
const [errors, setErrors] = useState<FormErrors>({});
const [isSubmitting, setIsSubmitting] = useState(false);
const [isCompleted, setIsCompleted] = useState(false);
const updateField = (field: keyof FormData, value: string | boolean) => {
setFormData((prev) => ({ ...prev, [field]: value }));
// Clear field error on edit
if (errors[field as keyof FormErrors]) {
setErrors((prev) => ({ ...prev, [field]: undefined }));
}
};
const handleNext = () => {
const stepErrors: FormErrors = {};
if (currentStep === 0) {
if (!formData.fullName.trim()) stepErrors.fullName = 'Full name is required.';
if (!formData.email.trim() || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
stepErrors.email = 'Valid email is required.';
}
} else if (currentStep === 1) {
if (!formData.username.trim()) stepErrors.username = 'Username is required.';
}
if (Object.keys(stepErrors).length > 0) {
setErrors(stepErrors);
return;
}
setErrors({});
setCurrentStep((prev) => Math.min(prev + 1, STEPS.length - 1));
};
const handlePrevious = () => {
setErrors({});
setCurrentStep((prev) => Math.max(prev - 1, 0));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsSubmitting(true);
try {
// Simulate async API network call
await new Promise((resolve) => setTimeout(resolve, 800));
setIsCompleted(true);
onSubmitSuccess?.(formData);
} finally {
setIsSubmitting(false);
}
};
if (isCompleted) {
return (
<div className="success-screen" role="status" aria-live="polite">
<h2>Registration Complete!</h2>
<p>Welcome, {formData.fullName}. Your account has been created.</p>
</div>
);
}
return (
<div className="wizard-card">
<div className="screen-reader-announcer" aria-live="polite" style={{ position: 'absolute', left: '-9999px' }}>
Step {currentStep + 1} of {STEPS.length}: {STEPS[currentStep].title}
</div>
<nav aria-label="Registration steps" className="progress-nav">
<ol className="step-indicator">
{STEPS.map((s, idx) => (
<li
key={s.id}
className={`step-bubble ${idx === currentStep ? 'active' : ''} ${idx < currentStep ? 'done' : ''}`}
aria-current={idx === currentStep ? 'step' : undefined}
>
{s.title}
</li>
))}
</ol>
</nav>
<form onSubmit={handleSubmit} noValidate>
{currentStep === 0 && (
<fieldset>
<legend>Personal Details</legend>
<div className="input-group">
<label htmlFor="fullName">Full Name</label>
<input
id="fullName"
type="text"
value={formData.fullName}
onChange={(e) => updateField('fullName', e.target.value)}
aria-invalid={Boolean(errors.fullName)}
aria-describedby={errors.fullName ? 'fullName-err' : undefined}
/>
{errors.fullName && <span id="fullName-err" className="error" role="alert">{errors.fullName}</span>}
</div>
<div className="input-group">
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
value={formData.email}
onChange={(e) => updateField('email', e.target.value)}
aria-invalid={Boolean(errors.email)}
aria-describedby={errors.email ? 'email-err' : undefined}
/>
{errors.email && <span id="email-err" className="error" role="alert">{errors.email}</span>}
</div>
</fieldset>
)}
{currentStep === 1 && (
<fieldset>
<legend>Account Configuration</legend>
<div className="input-group">
<label htmlFor="username">Username</label>
<input
id="username"
type="text"
value={formData.username}
onChange={(e) => updateField('username', e.target.value)}
aria-invalid={Boolean(errors.username)}
aria-describedby={errors.username ? 'username-err' : undefined}
/>
{errors.username && <span id="username-err" className="error" role="alert">{errors.username}</span>}
</div>
<div className="input-group">
<label htmlFor="accountType">Account Type</label>
<select
id="accountType"
value={formData.accountType}
onChange={(e) => updateField('accountType', e.target.value as 'personal' | 'business')}
>
<option value="personal">Personal</option>
<option value="business">Business</option>
</select>
</div>
</fieldset>
)}
{currentStep === 2 && (
<div className="summary-step">
<h3>Review Information</h3>
<p><strong>Name:</strong> {formData.fullName}</p>
<p><strong>Email:</strong> {formData.email}</p>
<p><strong>Username:</strong> {formData.username}</p>
<p><strong>Type:</strong> {formData.accountType}</p>
</div>
)}
<div className="actions">
{currentStep > 0 && (
<button type="button" onClick={handlePrevious} disabled={isSubmitting}>
Previous
</button>
)}
{currentStep < STEPS.length - 1 ? (
<button type="button" onClick={handleNext}>
Next Step
</button>
) : (
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Creating Account...' : 'Confirm & Register'}
</button>
)}
</div>
</form>
</div>
);
}
5. Accessibility and Edge Cases
WAI-ARIA and Screen Reader Audit
- Step Indicator Semantics: Using
<ol>witharia-current="step"informs assistive devices exactly which phase of the workflow is currently active. - Dynamic Live Regions: An off-screen element with
aria-live="polite"announces"Step X of Y: Step Title"as soon as the user transitions steps, preventing screen reader users from becoming disoriented. - Form Error Association: Every invalid field pairs
aria-invalid="true"witharia-describedby="field-id-err". Screen readers automatically read the error message when the user focuses the invalid field. - Fieldset and Legend Grouping: Related inputs are grouped within
<fieldset>and described by<legend>, providing contextual labels for radio groups and address blocks.
Critical Edge Cases Handled
- Preserving State on Backward Navigation: When a user clicks "Previous", no inputs are wiped or re-initialized.
- Submitting on Enter Key: In multi-step forms, pressing
Enteron an input inside Step 1 should trigger "Next", not trigger a form submission. The component separates button types (type="button"for Next/Previous,type="submit"only on the final step). - Double-Submission Prevention: The submission button is disabled while
isSubmittingis true, preventing duplicate network requests. - Error Cleansing on Change: As soon as a user edits an invalid input, the corresponding error is cleared, providing immediate positive feedback.
6. Automated Testing with React Testing Library
Here is the automated test suite verifying step navigation, validation blocking, and successful submission:
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MultiStepFormWizard } from './MultiStepFormWizard';
describe('MultiStepFormWizard', () => {
it('blocks step progression when step 1 inputs are invalid', async () => {
render(<MultiStepFormWizard />);
// Attempt to advance without entering data
fireEvent.click(screen.getByRole('button', { name: /next step/i }));
// Verify validation errors appear
expect(screen.getByText(/full name is required/i)).toBeInTheDocument();
expect(screen.getByText(/valid email is required/i)).toBeInTheDocument();
});
it('allows navigation forward and backward while preserving state', async () => {
const user = userEvent.setup();
render(<MultiStepFormWizard />);
// Fill Step 1
await user.type(screen.getByLabelText(/full name/i), 'Sarah Connor');
await user.type(screen.getByLabelText(/email/i), 'sarah@example.com');
await user.click(screen.getByRole('button', { name: /next step/i }));
// Verify on Step 2
expect(screen.getByLabelText(/username/i)).toBeInTheDocument();
// Click Previous
await user.click(screen.getByRole('button', { name: /previous/i }));
// Verify data was preserved
expect(screen.getByLabelText(/full name/i)).toHaveValue('Sarah Connor');
expect(screen.getByLabelText(/email/i)).toHaveValue('sarah@example.com');
});
it('submits form on final step and displays success message', async () => {
const user = userEvent.setup();
const handleSubmit = jest.fn();
render(<MultiStepFormWizard onSubmitSuccess={handleSubmit} />);
// Step 1
await user.type(screen.getByLabelText(/full name/i), 'John Doe');
await user.type(screen.getByLabelText(/email/i), 'john@example.com');
await user.click(screen.getByRole('button', { name: /next step/i }));
// Step 2
await user.type(screen.getByLabelText(/username/i), 'johndoe');
await user.click(screen.getByRole('button', { name: /next step/i }));
// Step 3 (Review & Submit)
expect(screen.getByText(/review information/i)).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: /confirm & register/i }));
// Await completion
await waitFor(() => {
expect(screen.getByText(/registration complete/i)).toBeInTheDocument();
});
expect(handleSubmit).toHaveBeenCalledTimes(1);
});
});
7. Extensions and Advanced Patterns
Local Storage Persistence and Draft Saving
In production wizards (like onboarding flows or checkout funnels), users frequently navigate away or accidentally reload the page. Persisting form state in localStorage prevents data loss:
import { useEffect, useState } from 'react';
export function useFormDraft(key: string, initialData: FormData) {
const [data, setData] = useState<FormData>(() => {
if (typeof window === 'undefined') return initialData;
const saved = localStorage.getItem(key);
return saved ? JSON.parse(saved) : initialData;
});
useEffect(() => {
localStorage.setItem(key, JSON.stringify(data));
}, [key, data]);
const clearDraft = () => localStorage.removeItem(key);
return [data, setData, clearDraft] as const;
}
Dynamic Branching and Async Validation
In senior interviews, interviewers often ask: "How would you support conditional steps based on user choices?"
- Dynamic Step Arrays: Rather than hardcoding steps, compute the step list dynamically based on form data (e.g., if
accountType === 'business', insert a "Company Details" step between Steps 1 and 2). - Asynchronous Uniqueness Checks: Debounce an asynchronous check on the
usernameinput (e.g.checkUsernameAvailability(name)), setting anisValidatingboolean and blocking "Next" until the check resolves.
Preparation Checklist
Before your next front-end machine-coding interview, verify you can comfortably implement these key patterns:
- Lifting Form State: Store all step inputs in a unified top-level object to guarantee data preservation during navigation.
- Step Validation Fencing: Validate only the active step before advancing, avoiding prematurely displaying errors for future fields.
- Accessible Step Indicators: Use
aria-current="step"androle="progressbar"with standard min/max/current values. - Error Announcement: Pair invalid fields with
aria-invalid="true"andaria-describedbypointing to error IDs. - Button Typing: Ensure step transition buttons use
type="button", reservingtype="submit"solely for the final review step. - Automated RTL Testing: Write tests verifying validation stops progression, backward navigation preserves state, and submission fires.
Conclusion
Multi-step form wizards are a staple of front-end machine-coding interviews because they test complete UI engineering: state architecture, boundary validation, keyboard ergonomics, and WAI-ARIA accessibility. Demonstrating a structured 45-minute execution plan and writing clean, tested TypeScript code signals to interviewers that you can deliver production-grade user experiences independently and under pressure.