Hey folks, Rahul here ๐
Checkout flows, onboarding wizards, insurance applications โ multi-step forms are everywhere. And most implementations are fragile: lose state on refresh, validate at the wrong time, and break when the user hits the back button.
I've seen a checkout form that lost a user's credit card info on browser back-button. That's a conversion-killing bug. Let's design one that never loses data.
R โ Requirements
Functional Requirements
- Navigate between steps (next, previous, jump to step)
- Step validation before progression
- Conditional steps (skip steps based on previous answers)
- Progress indicator showing current position
- Save draft / resume later
- Review step before final submission
- File uploads within steps
Non-Functional Requirements
- Persistence: Survive page refresh, tab close, browser back
- Validation: Per-step + cross-step + async (email uniqueness check)
- Performance: Lazy-load heavy steps (file upload, rich text)
- Accessibility: Focus management on step transitions + progress announcements
- Analytics: Track drop-off per step for funnel optimization
A โ Architecture
State Machine Approach
The biggest mistake candidates make: modeling the wizard as a simple currentStep index. This breaks when you add conditional steps, validation gates, and async submission. Use a state machine:
type WizardState =
| { status: 'filling'; currentStep: string; direction: 'forward' | 'backward' }
| { status: 'validating'; currentStep: string }
| { status: 'submitting' }
| { status: 'success'; result: SubmissionResult }
| { status: 'error'; error: string; lastStep: string };
type WizardAction =
| { type: 'NEXT' }
| { type: 'PREVIOUS' }
| { type: 'JUMP_TO'; step: string }
| { type: 'VALIDATION_SUCCESS' }
| { type: 'VALIDATION_FAILURE'; errors: Record<string, string> }
| { type: 'SUBMIT' }
| { type: 'SUBMIT_SUCCESS'; result: SubmissionResult }
| { type: 'SUBMIT_FAILURE'; error: string }
| { type: 'SAVE_DRAFT' };Step Graph (Not Linear Array)
// Steps aren't always linear โ they form a directed graph
interface StepDefinition {
id: string;
title: string;
component: React.LazyExoticComponent<any>;
schema: z.ZodSchema; // Validation schema for this step
next: (data: FormData) => string | null; // Dynamic next step
canSkip?: boolean;
isVisible?: (data: FormData) => boolean; // Conditional visibility
}
const steps: StepDefinition[] = [
{
id: 'personal-info',
title: 'Personal Info',
component: lazy(() => import('./steps/PersonalInfo')),
schema: personalInfoSchema,
next: () => 'address',
},
{
id: 'address',
title: 'Address',
component: lazy(() => import('./steps/Address')),
schema: addressSchema,
next: (data) => data.needsBilling ? 'billing' : 'review', // Conditional!
},
{
id: 'billing',
title: 'Billing',
component: lazy(() => import('./steps/Billing')),
schema: billingSchema,
isVisible: (data) => data.needsBilling === true, // Only show if needed
next: () => 'review',
},
{
id: 'review',
title: 'Review',
component: lazy(() => import('./steps/Review')),
schema: z.object({}), // No validation on review step
next: () => null, // Final step
},
];D โ Data Model
Form State
interface WizardFormState {
// Step data โ keyed by step ID
data: Record<string, Record<string, any>>;
// Meta
currentStepId: string;
visitedSteps: Set<string>; // Track which steps user has been to
completedSteps: Set<string>; // Steps that passed validation
errors: Record<string, Record<string, string>>; // stepId โ field โ error
// Persistence
draftId: string | null; // Server-side draft ID
lastSavedAt: string | null;
isDirty: boolean; // Has unsaved changes
// Files (handled separately from JSON data)
files: Record<string, File[]>; // stepId โ uploaded files
}
// Persist to sessionStorage for crash recovery
function usePersistentWizard(wizardId: string) {
const STORAGE_KEY = `wizard-${wizardId}`;
const [state, dispatch] = useReducer(wizardReducer, null, () => {
const saved = sessionStorage.getItem(STORAGE_KEY);
return saved ? JSON.parse(saved) : createInitialState();
});
// Auto-save on every state change
useEffect(() => {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify({
data: state.data,
currentStepId: state.currentStepId,
visitedSteps: [...state.visitedSteps],
completedSteps: [...state.completedSteps],
}));
}, [state]);
// Clear on successful submission
const clearDraft = () => sessionStorage.removeItem(STORAGE_KEY);
return { state, dispatch, clearDraft };
}Validation Architecture
import { z } from 'zod';
// Per-step schemas
const personalInfoSchema = z.object({
firstName: z.string().min(1, 'Required'),
lastName: z.string().min(1, 'Required'),
email: z.string().email('Invalid email'),
phone: z.string().regex(/^\+?[1-9]\d{7,14}$/, 'Invalid phone').optional(),
});
const addressSchema = z.object({
street: z.string().min(1, 'Required'),
city: z.string().min(1, 'Required'),
state: z.string().min(1, 'Required'),
zip: z.string().min(5, 'Invalid zip code'),
country: z.string().min(1, 'Required'),
needsBilling: z.boolean(),
});
// Cross-step validation (runs before final submission)
const fullFormSchema = z.object({
personalInfo: personalInfoSchema,
address: addressSchema,
billing: billingSchema.optional(),
}).refine(
(data) => !(data.address.needsBilling && !data.billing),
{ message: 'Billing information required', path: ['billing'] }
);
// Async validation (debounced)
async function validateEmailUnique(email: string): Promise<string | null> {
const { exists } = await api.get(`/check-email?email=${email}`);
return exists ? 'Email already registered' : null;
}I โ Interface Definition
Wizard Hook API
function useWizard(config: WizardConfig) {
return {
// Navigation
currentStep: StepDefinition;
currentStepIndex: number;
totalSteps: number;
visibleSteps: StepDefinition[]; // After conditional filtering
goNext: () => Promise<boolean>; // Returns false if validation fails
goPrevious: () => void;
jumpTo: (stepId: string) => void;
canGoNext: boolean;
canGoPrevious: boolean;
isFirstStep: boolean;
isLastStep: boolean;
// Data
stepData: Record<string, any>; // Current step's data
setStepData: (field: string, value: any) => void;
allData: Record<string, Record<string, any>>;
// Validation
errors: Record<string, string>; // Current step's errors
isValidating: boolean;
// Submission
submit: () => Promise<void>;
isSubmitting: boolean;
// Persistence
saveDraft: () => Promise<void>;
isDirty: boolean;
lastSavedAt: string | null;
// Progress
progress: number; // 0-100
visitedSteps: Set<string>;
completedSteps: Set<string>;
};
}Step Component Contract
interface StepComponentProps {
data: Record<string, any>;
onChange: (field: string, value: any) => void;
errors: Record<string, string>;
allFormData: Record<string, Record<string, any>>; // Access other steps' data
}
// Each step is a standalone form section
function PersonalInfoStep({ data, onChange, errors }: StepComponentProps) {
return (
<div className="space-y-4">
<div>
<Label htmlFor="firstName">First Name *</Label>
<Input
id="firstName"
value={data.firstName || ''}
onChange={(e) => onChange('firstName', e.target.value)}
aria-invalid={!!errors.firstName}
aria-describedby={errors.firstName ? 'firstName-error' : undefined}
/>
{errors.firstName && (
<p id="firstName-error" className="text-sm text-destructive mt-1">
{errors.firstName}
</p>
)}
</div>
{/* ... more fields */}
</div>
);
}O โ Optimizations
1. Focus Management on Step Transitions
function StepContainer({ step, direction }: { step: StepDefinition; direction: 'forward' | 'backward' }) {
const headingRef = useRef<HTMLHeadingElement>(null);
useEffect(() => {
// Focus the step heading on transition
headingRef.current?.focus();
// Announce to screen readers
announce(`Step ${stepIndex + 1} of ${totalSteps}: ${step.title}`);
}, [step.id]);
return (
<motion.div
key={step.id}
initial={{ x: direction === 'forward' ? 100 : -100, opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
exit={{ x: direction === 'forward' ? -100 : 100, opacity: 0 }}
transition={{ duration: 0.2 }}
>
<h2 ref={headingRef} tabIndex={-1} className="text-lg font-semibold outline-none">
{step.title}
</h2>
<Suspense fallback={<StepSkeleton />}>
<step.component {...stepProps} />
</Suspense>
</motion.div>
);
}2. Progress Indicator with Clickable Steps
function WizardProgress({ steps, currentIndex, completedSteps, visitedSteps }: WizardProgressProps) {
return (
<nav aria-label="Form progress">
<ol className="flex items-center gap-2">
{steps.map((step, i) => {
const isCompleted = completedSteps.has(step.id);
const isCurrent = i === currentIndex;
const isClickable = visitedSteps.has(step.id) || isCompleted;
return (
<li key={step.id} className="flex items-center">
<button
onClick={() => isClickable && jumpTo(step.id)}
disabled={!isClickable}
aria-current={isCurrent ? 'step' : undefined}
className={cn(
"w-8 h-8 rounded-full flex items-center justify-center text-xs font-medium",
isCompleted && "bg-primary text-primary-foreground",
isCurrent && "border-2 border-primary text-primary",
!isCompleted && !isCurrent && "bg-muted text-muted-foreground"
)}
>
{isCompleted ? <Check className="h-4 w-4" /> : i + 1}
</button>
{i < steps.length - 1 && (
<div className={cn("w-12 h-0.5 mx-1", isCompleted ? "bg-primary" : "bg-muted")} />
)}
</li>
);
})}
</ol>
</nav>
);
}3. Debounced Auto-Save to Server
function useAutoSaveDraft(wizardId: string, formData: Record<string, any>, userId: string | null) {
const debouncedSave = useMemo(
() => debounce(async (data: Record<string, any>) => {
if (!userId) return; // Only save for authenticated users
await api.put(`/drafts/${wizardId}`, {
data,
updated_at: new Date().toISOString(),
});
}, 3000), // Save 3s after last change
[wizardId, userId]
);
useEffect(() => {
debouncedSave(formData);
return () => debouncedSave.cancel();
}, [formData, debouncedSave]);
}4. Analytics: Step Drop-Off Tracking
function useStepAnalytics(wizardId: string, currentStepId: string) {
const stepStartTime = useRef(Date.now());
// Track time spent on each step
useEffect(() => {
stepStartTime.current = Date.now();
return () => {
const timeSpent = Date.now() - stepStartTime.current;
analytics.track('wizard_step_time', {
wizardId,
stepId: currentStepId,
timeSpentMs: timeSpent,
});
};
}, [currentStepId]);
// Track drop-off (user leaves without completing)
useEffect(() => {
const handleBeforeUnload = () => {
analytics.track('wizard_abandon', {
wizardId,
lastStepId: currentStepId,
// This tells you WHERE users give up
});
};
window.addEventListener('beforeunload', handleBeforeUnload);
return () => window.removeEventListener('beforeunload', handleBeforeUnload);
}, [currentStepId]);
}5. Browser Back Button Integration
function useWizardHistory(currentStepId: string, goPrevious: () => void) {
useEffect(() => {
// Push a history entry for each step
window.history.pushState({ step: currentStepId }, '');
const handlePopState = (e: PopStateEvent) => {
if (e.state?.step) {
goPrevious();
}
};
window.addEventListener('popstate', handlePopState);
return () => window.removeEventListener('popstate', handlePopState);
}, [currentStepId]);
}Production Gotchas Rahul Has Debugged ๐ฅ
- Validation Timing: Validate on blur (not on change) for text fields โ showing errors while the user is still typing is hostile UX. Validate on change only for selects and checkboxes.
- Browser Autofill: Chrome's autofill fires synthetic change events that bypass React's onChange. Listen for
inputevents as a backup, and addautoCompleteattributes to help the browser fill correctly across steps. - File Upload Persistence:
Fileobjects can't be serialized to JSON/sessionStorage. Store file metadata (name, size, type) and re-upload on resume, or use presigned URLs and store the uploaded URL instead. - Conditional Step Loops: If step B's visibility depends on step A's data, and the user goes back to step A and changes their answer, step B's data is now stale. Clear dependent steps' data when their visibility condition changes.
- Double Submission: Disable the submit button is not enough โ users can press Enter. Set a submitting flag and reject duplicate calls at the API level with an idempotency key.
Next: #13: Design a Video Player โ adaptive bitrate streaming, custom controls, buffering strategies, and PiP mode. ๐ฌ