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
Step Component Contract
O โ Optimizations
1. Focus Management on Step Transitions
2. Progress Indicator with Clickable Steps
3. Debounced Auto-Save to Server
4. Analytics: Step Drop-Off Tracking
5. Browser Back Button Integration
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. ๐ฌ