How would you ensure that data entered in each step of the form is stored properly and can be accessed across steps
Lift the whole multi-step form's state to a parent (or context/reducer) above the step components, not inside each step. Each step reads/writes its slice; steps unmount without losing data. Persist to storage for refresh-safety, validate per step, and submit the aggregated object at the end.
Multi-step (wizard) forms have one core requirement: data must survive navigating between steps, even though step components mount and unmount. The answer is where the state lives.
Lift the state above the steps
The form data must live in a single owner that outlives the individual steps — a parent component, a context, or a reducer:
function Wizard() {
const [formData, setFormData] = useState({
account: {}, profile: {}, payment: {},
});
const [step, setStep] = useState(0);
const updateStep = (key, values) =>
setFormData((d) => ({ ...d, [key]: { ...d[key], ...values } }));
const steps = [
<AccountStep data={formData.account} onChange={(v) => updateStep("account", v)} />,
<ProfileStep data={formData.profile} onChange={(v) => updateStep("profile", v)} />,
<PaymentStep data={formData.payment} onChange={(v) => updateStep("payment", v)} />,
];
return <>{steps[step]}{/* + nav */}</>;
}If state lived inside each step component, it would be destroyed when that step unmounts on navigation. Lifting it up (or into context / a useReducer) is the whole trick — each step just reads and writes its own slice.
The pieces
- Single source of truth — one
formDataobject (or reducer state). For deeply nested wizards,useReduceror a context keeps it clean and avoids prop-drilling through many steps. - Each step is "controlled" by the parent — receives its slice as props, reports changes up. Steps stay stateless/dumb.
- Per-step validation — validate a step's slice before allowing "Next"; keep errors in the shared state too.
- Refresh-safety — persist
formDatatosessionStorage/localStorage(debounced) so a refresh or accidental nav-away doesn't wipe a long form. Optionally save partial progress to the server. - Final submit — assemble the full
formDataobject and send it once at the end; show a review step summarizing all slices. - Keep steps mounted vs unmounted — either is fine as long as state is lifted; unmounting saves memory, and lifted state means nothing is lost.
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 entire form into one owner above them: a parent useState/useReducer or a context. Each step is controlled — it gets its slice as props and reports changes up. I validate each step's slice before 'Next', persist the whole object to sessionStorage so a refresh doesn't lose progress, and submit the assembled object once at the end after a review step."
Follow-up questions
- •Why can't the state live inside each step component?
- •When would you use useReducer or context instead of useState here?
- •How do you make a long multi-step form survive a page refresh?
- •How do you handle per-step validation before allowing Next?
Common mistakes
- •Keeping each step's state local — it's lost on navigation.
- •Not persisting, so a refresh wipes a long form.
- •Prop-drilling form data through many intermediate components instead of context.
- •Submitting per-step instead of assembling and submitting the whole object.
Performance considerations
- •A single shared state object means an update can re-render the whole wizard — scope updates per slice, and memoize step components so only the active step re-renders. Debounce persistence writes.
Edge cases
- •User refreshes mid-wizard.
- •User navigates back and edits an earlier step — later steps may need revalidation.
- •Browser back button vs. in-app Next/Back.
- •Abandoned forms — partial server-side save.
Real-world examples
- •Checkout flows (shipping → payment → review), onboarding wizards, multi-page surveys.
- •Job application forms that save progress and let users resume later.