- React
- Live Machine Coding Challenges and Custom Hooks
- Machine Coding: Multi-Step Wizard with Step Validation
Machine Coding: Multi-Step Wizard with Step Validation
A production Multi-Step Form Wizard manages step navigation as a finite state machine, persists accumulated form data across steps, isolates field validation per step, supports backward navigation without data loss, and submits a consolidated payload upon final review.
Key Learning Objectives
Architect a multi-step wizard supporting dynamic step transitions and review summaries.
Validate form fields on a per-step basis before permitting advancement to subsequent steps.
Preserve form draft data across forward and backward step navigation.
Build step progress indicators with accessible ARIA progress contracts.
The Interview Problem & Context
Implement a production 3-step Wizard Form (Account -> Profile -> Review & Submit) with step validation, data persistence, and backward navigation.
1import React, { useState } from 'react';23interface FormData {4 email: string;5 username: string;6 role: string;7 acceptTerms: boolean;8}910const INITIAL_DATA: FormData = {11 email: '',12 username: '',13 role: 'developer',14 acceptTerms: false,15};1617export function MultiStepWizard({ onSubmit }: { onSubmit: (data: FormData) => void }) {18 const [currentStep, setCurrentStep] = useState(0);19 const [formData, setFormData] = useState<FormData>(INITIAL_DATA);20 const [errors, setErrors] = useState<Record<string, string>>({});2122 const updateFields = (fields: Partial<FormData>) => {23 setFormData((prev) => ({ ...prev, ...fields }));24 setErrors({}); // Clear errors on change25 };2627 // Step 1 Validation: Account Info28 const validateStep1 = () => {29 const errs: Record<string, string> = {};30 if (!formData.email.includes('@')) errs.email = 'Valid email is required';31 setErrors(errs);32 return Object.keys(errs).length === 0;33 };3435 // Step 2 Validation: Profile Info36 const validateStep2 = () => {37 const errs: Record<string, string> = {};38 if (formData.username.trim().length < 3) errs.username = 'Username must be at least 3 chars';39 setErrors(errs);40 return Object.keys(errs).length === 0;41 };4243 const handleNext = () => {44 if (currentStep === 0 && !validateStep1()) return;45 if (currentStep === 1 && !validateStep2()) return;46 setCurrentStep((prev) => prev + 1);47 };4849 const handleBack = () => {50 setErrors({});51 setCurrentStep((prev) => Math.max(0, prev - 1));52 };5354 const handleSubmit = (e: React.FormEvent) => {55 e.preventDefault();56 if (!formData.acceptTerms) {57 setErrors({ terms: 'Must accept terms to proceed' });58 return;59 }60 onSubmit(formData);61 };6263 const steps = ['Account', 'Profile', 'Review'];6465 return (66 <div className="wizard-container" style={{ maxWidth: '400px', margin: '0 auto', padding: '20px' }}>67 {/* Progress Indicator */}68 <nav aria-label="Form Progress" style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '24px' }}>69 {steps.map((label, idx) => (70 <div key={label} style={{ fontWeight: currentStep === idx ? 'bold' : 'normal' }}>71 Step {idx + 1}: {label}72 </div>73 ))}74 </nav>7576 {/* Step 1: Account Info */}77 {currentStep === 0 && (78 <div className="step-panel">79 <h3>Account Information</h3>80 <label>Email Address</label>81 <input82 type="email"83 value={formData.email}84 onChange={(e) => updateFields({ email: e.target.value })}85 style={{ display: 'block', width: '100%', marginBottom: '8px' }}86 />87 {errors.email && <p style={{ color: 'red' }}>{errors.email}</p>}88 </div>89 )}9091 {/* Step 2: Profile Info */}92 {currentStep === 1 && (93 <div className="step-panel">94 <h3>Profile Setup</h3>95 <label>Username</label>96 <input97 type="text"98 value={formData.username}99 onChange={(e) => updateFields({ username: e.target.value })}100 style={{ display: 'block', width: '100%', marginBottom: '8px' }}101 />102 {errors.username && <p style={{ color: 'red' }}>{errors.username}</p>}103104 <label>Primary Role</label>105 <select106 value={formData.role}107 onChange={(e) => updateFields({ role: e.target.value })}108 style={{ display: 'block', width: '100%', marginTop: '8px' }}109 >110 <option value="developer">Developer</option>111 <option value="designer">Designer</option>112 <option value="manager">Product Manager</option>113 </select>114 </div>115 )}116117 {/* Step 3: Review & Submit */}118 {currentStep === 2 && (119 <div className="step-panel">120 <h3>Review & Confirm</h3>121 <p>Email: {formData.email}</p>122 <p>Username: {formData.username}</p>123 <p>Role: {formData.role}</p>124125 <label>126 <input127 type="checkbox"128 checked={formData.acceptTerms}129 onChange={(e) => updateFields({ acceptTerms: e.target.checked })}130 />{' '}131 I accept the terms and conditions132 </label>133 {errors.terms && <p style={{ color: 'red' }}>{errors.terms}</p>}134 </div>135 )}136137 {/* Navigation Buttons */}138 <div style={{ display: 'flex', justifyContent: 'space-between', marginTop: '24px' }}>139 {currentStep > 0 && (140 <button type="button" onClick={handleBack}>141 Back142 </button>143 )}144 {currentStep < 2 ? (145 <button type="button" onClick={handleNext}>146 Next Step147 </button>148 ) : (149 <button type="button" onClick={handleSubmit}>150 Submit Registration151 </button>152 )}153 </div>154 </div>155 );156}
Deep Architectural Explanation
Architectural Decisions in Multi-Step Form Wizards
1. Centralized vs Distributed State:
- Distributed State (Anti-pattern): Storing email in Step 1 and username in Step 2 causes state loss whenever steps unmount!
- Consolidated State (Recommended): The parent <MultiStepWizard> owns the authoritative formData state. Steps receive data and callbacks via props or context.
- Moving between steps merely mounts different UI views while the underlying accumulated state persists untouched.
2. Isolated Step Validation:
- Validating the entire form on step 1 would flag step 2 and step 3 fields as empty errors.
- Therefore, each step executes its own scoped validator before calling setCurrentStep(prev => prev + 1).
3. State Machine Modeling:
- Steps represent states in a finite state machine:
[Account] -(Valid)-> [Profile] -(Valid)-> [Review] -(Submit)-> [Complete] - Invalid inputs cannot trigger state transitions.
- Backward transitions are always permitted and never require validation.
Common Interview Traps & Pitfalls
Storing form state inside individual step components, losing user data whenever the step unmounts on Back or Next.
Validating future step fields prematurely, confusing users with errors on inputs they have not seen yet.
Allowing step advancement via URL or buttons without running step validation checks.
Senior Interviewer Follow-Up Probes
How does React Hook Form or Zod integrate with multi-step schemas using `z.discriminatedUnion`?
How would you allow users to jump directly to any previously completed step via the breadcrumb indicator?
How do you prevent duplicate form submissions on network latency in the final submit step?
Frequently Asked Interview Questions
How do you validate a multi-step form?
Validate only the fields belonging to the current active step before allowing the user to advance to the next step, avoiding premature validation errors on future fields.
How do you preserve form data when moving backward?
Because all form data is retained in the parent component state, moving backward simply renders the previous step panel with existing state pre-populated.
Can you use URL query parameters for wizard steps?
Yes. Storing ?step=2 in the URL allows browser back/forward buttons to navigate between steps naturally and enables deep linking.
How do you persist wizard draft state across browser reloads?
Save the formData and active step in sessionStorage on each change, and initialize state from sessionStorage on component mount.
How do you handle conditional steps that only appear for specific users?
Compute the active steps array dynamically based on user selections in previous steps (e.g., if user selects "Business", inject a "Company Details" step).
