Home/Learn/React/Multi-Step Forms and Wizards

Multi-Step Forms and Wizards

Advanced
Forms

One 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.

One values object, 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, merged for the final check
// 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, exclude secrets, clear on completion
// 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 Aria
1

Why should the current step live in the URL rather than in component state?

Medium
2

How do you validate a multi-step form — per step, at the end, or both?

Medium
3

What should and should not be included when autosaving a wizard draft?

Hard

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.

Loading discussion…