Build a Multi-step form with validation
Lift the whole form's data above the steps (parent state / reducer / context) so it survives step navigation. Track currentStep; validate the current step's slice before advancing; keep per-step errors in shared state; allow free Back; persist for refresh-safety; assemble and submit once at the end.
A multi-step (wizard) form has one defining requirement: data must survive navigating between steps even as step components mount and unmount. The whole design follows from that.
1. Lift the form data above the steps
The data lives in one owner that outlives the individual steps — a parent component, a reducer, or a context:
function Wizard() {
const [data, setData] = useState({ account: {}, profile: {}, payment: {} });
const [step, setStep] = useState(0);
const [errors, setErrors] = useState({});
const updateSlice = (key, values) =>
setData((d) => ({ ...d, [key]: { ...d[key], ...values } }));
// ...
}If state lived inside each step, it would be destroyed on unmount when you navigate. Each step is controlled — it receives its slice as props and reports changes up.
2. Validation gates "Next", not "Back"
const next = () => {
const stepErrors = validateStep(step, data);
if (Object.keys(stepErrors).length) {
setErrors((e) => ({ ...e, [step]: stepErrors }));
return; // blocked
}
setStep((s) => s + 1);
};
const back = () => setStep((s) => Math.max(0, s - 1));Validate the current step's slice before advancing; Back is always free — never make a user re-pass validation to look at a previous step. Use a schema per step (Zod) for clean, reusable validation.
3. Errors in shared state, keyed by step
Per-step errors live in the shared state (errors[step]), not in the step components — they unmount. Show field-level errors inline.
4. The details
- Progress indicator / stepper — show which steps are done, current, or have errors.
- Persist
datatosessionStorage(debounced) so a refresh doesn't wipe a long form. - Sync
stepto the URL so the browser back button and refresh work. - Review step before submit summarizing all slices.
- Final submit — re-validate all steps, assemble the full object, submit once; jump to the first invalid step if something fails.
- Server re-validates — client validation is UX.
- For production: a form library (React Hook Form) handles per-step state and validation cleanly.
The framing
"The requirement is that data survives step navigation despite components unmounting — so the state can't live in the steps. I lift the whole form into one owner above them — parent state, a reducer, or context — and each step is controlled: it gets its slice as props and reports up. 'Next' is gated on validating the current step's slice, with a per-step schema; 'Back' is always free. Errors live in shared state keyed by step. Then the polish: a stepper UI, sessionStorage persistence for refresh-safety, step synced to the URL, a review step, and a final submit that re-validates everything and assembles the full object."
Follow-up questions
- •Why can't the form state live inside each step component?
- •Why gate Next on validation but allow Back freely?
- •How do you make a long wizard survive a page refresh?
- •How do you validate before the final submit?
Common mistakes
- •Keeping each step's data in local state — lost on navigation.
- •Blocking Back navigation behind validation.
- •Storing per-step errors in the step components.
- •Not persisting — refresh wipes the form.
- •Submitting per-step instead of assembling the whole object.
Performance considerations
- •One shared state object means an update can re-render the whole wizard — memoize step components so only the active step renders, scope updates per slice, and debounce persistence writes.
Edge cases
- •Refresh mid-wizard.
- •Editing an earlier step that invalidates a later one.
- •Browser back button vs in-app Back.
- •Deep-linking to a middle step.
Real-world examples
- •Checkout flows, onboarding wizards, multi-page application/survey forms.
- •React Hook Form managing per-step state across a wizard.