How would you implement a multi-step wizard component in React?
Advertisement
🧩 Scenario
🧠 Architecture Walkthrough
Parent-Owned State Prevents Data Loss Across Step Unmounts
The most common mistake in wizard implementations is letting each step manage its own local state. It feels natural the personal info step "owns" first name and last name, so why not put that state inside PersonalInfoStep? The problem is that React unmounts a component when it is no longer rendered, and all local state is destroyed with it.
When the user fills in step 1 and advances to step 2, the step 1 component unmounts. When they click Back, step 1 remounts with empty state the form data is gone. The fix is to hoist all wizard data into a single formData object owned by the parent wizard component.
Each step receives only a data slice and an updateData callback. Step components become stateless display components that never own data they cannot afford to lose. This also makes the final Review step trivially easy it reads directly from the same parent formData object that all prior steps have been writing into, with no aggregation logic needed.
Step Registry Decouples Navigation from Step Content
The steps array in MultiStepWizard is a declarative registry: each entry holds an id, a label, an icon, and a component reference. This pattern means adding a new step requires only a new entry in the array the navigation logic, progress bar, and step counter all derive their behavior from steps.length automatically.
The currentStep integer is an index into this array, and steps[currentStep].component is stored in StepComponent and rendered directly. This approach avoids a giant switch statement or nested conditionals and makes the step order easy to reorder by shuffling array entries.
The demo also slices the steps array in the progress bar (steps.slice(0, -1)) to exclude the success step from the indicator, because displaying "Complete" as a clickable step in the progress bar would be misleading before the wizard is actually done.
Validation Is Step-Index-Aware, Not Schema-Driven
The validateStep function uses a switch statement keyed on the step index. This is a deliberate simplicity tradeoff: the validation rules are co-located with the wizard logic rather than distributed into each step component or encoded in a separate schema.
For a wizard with 3-4 steps, this is the most readable approach a developer can look at validateStep and immediately see every field that is validated on every step. The tradeoff is that validateStep becomes a maintenance liability as the wizard grows.
If you add a step at index 1, every subsequent case index must be incremented. A more scalable approach would attach a validate function to each step registry entry, so the wizard calls steps[currentStep].validate(formData) instead of a switch.
The demo's approach is correct for the stated scope and teaches the concept clearly; moving to per-step validators is a natural next evolution.
💡 Key Code Explained
const [formData, setFormData] = useLocalStorage('wizardFormData', {});
const [currentStep, setCurrentStep] = useLocalStorage('wizardCurrentStep', 0);
Both pieces of state are wired to localStorage via the custom useLocalStorage hook. This means if the user refreshes the browser mid-wizard, they are returned to exactly the step they left off at with all their data intact.
The hook itself reads from localStorage in the useState initializer function (the lazy initializer form useState(() => {...})) so the read happens once on mount rather than on every render.
Every subsequent setValue call writes to both React state and localStorage synchronously. A junior developer might ask why both values are persisted separately the answer is that restoring formData without currentStep would strand the user on step 1 even though they had already completed two steps; you need both to fully restore the session.
const updateData = (newData) => {
setFormData((prev) => ({ ...prev, ...newData }));
// Clear errors for updated fields
const updatedFields = Object.keys(newData);
setErrors((prev) => {
const newErrors = { ...prev };
updatedFields.forEach((field) => delete newErrors[field]);
return newErrors;
});
};
This updateData callback does two things in one call: it merges the new field values into the parent formData using a spread to preserve existing fields, and it clears validation errors for any field that was just updated.
The error clearing is important for UX without it, a user who triggers a validation error and then corrects the field would see the red error message persist until they click Next again. The pattern of clearing errors on change (rather than only on submit) makes the form feel responsive.
Step components call updateData({ firstName: e.target.value }) rather than receiving individual setters for each field, which keeps the step component API surface minimal regardless of how many fields a step contains.
⚖️ Tradeoffs
| Approach | Pro | Con |
|---|---|---|
Parent-owned flat formData object | Data survives step unmounts; Review step reads directly | All steps share one namespace field name collisions across steps silently overwrite each other |
| Per-step local state + aggregation on submit | Simpler step components; no naming collision risk | Data is lost on back navigation; aggregation logic must be correct at submit time |
| React Context for wizard state | Removes prop drilling across deeply nested step trees | Adds indirection; any context update rerenders all consumers including unrelated steps |
localStorage persistence via custom hook | Survives refresh; free progress recovery | Stale persisted step index can mismatch after code changes; requires explicit resetWizard on completion |
🎯 What Interviewers Actually Check
- Whether you know that step components must be stateless with data owned by the parent local step state is the number-one wizard bug
- Whether you handle the case where
validateStepis called on the Review step (step 3) the demo skips validation there because all fields have already been validated in prior steps - Whether your progress bar excludes the success step showing it as a progress node before completion confuses users
- Whether
resetWizardclearslocalStorageexplicitly in addition to resetting React state state reset without storage clear causes the hook to reload old data on next mount - Whether you recognize that the
steps.slice(0, -1)in the progress bar is intentional, not an off-by-one error
❓ Follow-Up Questions
- How would you move validation out of the
validateStepswitch statement and into each step's registry entry so the wizard is truly generic? - What happens if the user's session expires while they are on step 3 how do you handle the case where
localStoragehas data but the server session is gone? - How would you write integration tests for this wizard specifically, how do you test that navigating back does not clear data entered on a later step?
- If the wizard had 15 steps and each step fetched its own data from an API, how would you handle loading states without blocking step-to-step navigation?
- Your product manager wants to let users jump to any completed step from the progress bar. How do you decide which steps are "completed" in a way that is consistent with the validation logic?
🎮 Live Demo
📝 Summary
The multi-step wizard's central architectural decision is that all form data lives in the parent, not in individual step components this single choice prevents data loss on back navigation and makes the Review step free to implement.
The step registry pattern, where each step is a configuration object with a component reference, makes the wizard genuinely reusable: navigation, progress calculation, and the step counter all derive from steps.length rather than hardcoded values.
Persistence via localStorage adds resilience to page refreshes but requires an explicit resetWizard function that clears storage alongside React state, otherwise the hook reloads stale data on next mount.
The validation strategy a switch keyed on step index is intentionally simple for this scale and points toward the natural refactor of attaching a validate function to each step registry entry as the wizard grows.
Should each step have its own state or should the parent manage everything?
Prefer parent-managed state to avoid losing data when steps unmount, and keep the wizard predictable.
How do you validate each step?
Run validation on step change and block progression if errors persist.
Advertisement
Stay Updated
Get the latest frontend challenges, interview questions and tutorials delivered to your inbox.
Advertisement