Multi-Step Forms and Wizards
AdvancedOne state object, per-step validation, and a step in the URL. The hard parts are surviving a refresh and letting the user go backwards without losing anything.
Overview
A wizard is what you build when a form is too long to face in one screen: onboarding, checkout, a multi-part application. The shape is straightforward — one object holding every answer, a step index deciding what renders — but the failure modes are specific. If the step lives only in component state, the back button leaves the flow entirely and a refresh starts over. If validation only runs at the end, the user discovers a step-one error after five screens. And if answers are dropped when they navigate back, they will not come back a second time.
The Shape
All answers in one object; the step in the URL.
const STEPS = ['account', 'profile', 'goals', 'review']
function Onboarding() {
const [params, setParams] = useSearchParams()
const step = STEPS.indexOf(params.get('step') ?? 'account')
const [values, setValues] = useState(() => loadDraft() ?? {})
const goTo = (i) => setParams({ step: STEPS[i] }) // a history entry
const next = (stepValues) => {
setValues(v => ({ ...v, ...stepValues })) // merge, never replace
goTo(step + 1)
}
return (
<>
<Progress current={step} total={STEPS.length} />
{step === 0 && <AccountStep defaults={values} onNext={next} />}
{step === 1 && <ProfileStep defaults={values} onNext={next} onBack={() => goTo(0)} />}
{step === 3 && <Review values={values} onSubmit={submitAll} />}
</>
)
}
// Step in the URL means: back button works, refresh keeps position,
// and a support conversation can say "go to ?step=goals".Validation and Going Back
Validate each step as it is left, and keep every answer when the user returns.
// Per-step schemas, composed into the whole
const AccountSchema = z.object({ email: z.string().email(), password: z.string().min(8) })
const ProfileSchema = z.object({ name: z.string().min(2), college: z.string() })
const FullSchema = AccountSchema.merge(ProfileSchema).merge(GoalsSchema)
// Each step validates only its own slice on submit
const form = useForm({ resolver: zodResolver(AccountSchema), defaultValues: defaults })
// Validate the whole thing once more before the final submit — a
// step could have been skipped by URL manipulation
const parsed = FullSchema.safeParse(values)
if (!parsed.success) return goToFirstInvalidStep(parsed.error)
// Going back must not clear anything: pass 'values' as defaultValues
// on every step, so a returning user sees exactly what they typed.
// Let users jump back to any COMPLETED step from the progress bar,
// but not forward past an incomplete one.
<button disabled={i > furthestCompleted} onClick={() => goTo(i)}>Surviving a Refresh
Persist the draft, and be careful about what you persist.
// Autosave the draft, debounced
useEffect(() => {
const id = setTimeout(() => {
const { password, cardNumber, ...safe } = values // never persist these
localStorage.setItem('onboarding-draft', JSON.stringify(safe))
}, 500)
return () => clearTimeout(id)
}, [values])
// Clear it once the flow completes, or the next user of a shared
// machine resumes someone else's application
localStorage.removeItem('onboarding-draft')
// Never store passwords, card numbers, OTPs or ID numbers in
// localStorage — the draft outlives the session and is readable by
// any script on the origin.
// For a long or valuable flow, save each step to the server instead,
// so the draft follows the user across devices:
await patch(`/api/applications/${id}`, stepValues)
// And warn on accidental exit
useBeforeUnload(isDirty)Key Points to Remember
- 1Keep every answer in one object and merge each step's values rather than replacing them
- 2Put the step in the URL so the back button, refresh and shared links all behave
- 3Validate each step's own schema on leaving it, then validate the merged schema before the final submit
- 4Returning to a previous step must show exactly what the user typed — pass the values back as defaults
- 5Persist the draft but never persist passwords, card numbers or OTPs, and clear it when the flow completes
Interview Questions
Sign in to ask AriaWhy should the current step live in the URL rather than in component state?
How do you validate a multi-step form — per step, at the end, or both?
What should and should not be included when autosaving a wizard draft?
Ask Aria about Multi-Step Forms and Wizards
Your personal AI tutor — ask anything about this concept
Revision Status
Personal Notes
Sign in to save personal notes for this topic.
Discussion
Sign in to join the discussion.