React Multi-Step Form: The Complete Guide (with Cheat Sheet)
Build a React multi-step form that validates per step, survives a refresh, and stays accessible — the mental model, the traps, and a copy-paste cheat sheet.

title: "React Multi-Step Form: The Complete Guide (with Cheat Sheet)" description: "Build a React multi-step form that validates per step, survives a refresh, and stays accessible — the mental model, the traps, and a copy-paste cheat sheet." tags: [react, typescript, tutorial, webdev] published: true series: "React Toolbox" package: react-form-wizard-component version: 2.0.0 previousVersion: 1.2.1 ecosystem: react tagline: "Accessible multi-step form wizard for React 17, 18 and 19 — zero dependencies, styled or headless." highlights:
- New default look, and dark mode that actually works
- Tailwind token bridge and utility preset
- Same API — legacy skin is one opt-in line linkedin: | Multi-step forms look like two useState calls until you ship one.
• A wizard is really three things wearing one component's clothes: a cursor, a shared data object, and a per-step gate. Name them and every bug has an address. • Per-step validation does NOT mean four separate forms — one schema, one form, a gate that picks its own fields. • An embedded component should never read prefers-color-scheme. A light page on a dark-mode laptop gets a dark widget on white.
The signup form has four steps. Step three uploads a passport photo and takes eight seconds. A user reaches step four, fat-fingers ⌘R, and lands back on step one with an empty form.
You already know how to fix that. You also know that the moment the step index stops living in useState, the browser back button, the per-step validation, and the focus ring all become your problem too.
Multi-step forms look like two useState calls until you ship one.
By the end of this article you'll be able to:
- Model any React multi-step form as three separate things — a cursor, shared data, and a per-step gate — and know which one a given bug belongs to
- Validate one step's fields without splitting your form into four forms
- Keep wizard state across a refresh and a shared link, without a router
- Explain why an embedded component should ignore
prefers-color-scheme - Ship the whole thing with correct ARIA roles, focus movement, and keyboard support
You've built a React form with controlled inputs and reached for a validation library at least once. TypeScript is used throughout but nothing here depends on it.
- Why multi-step forms are harder than they look
- What people build today
- Where the usual options actually break
- The mental model
- Enter react-form-wizard-component
- The API, in four stages
- What's new in v2.0
- Three recipes
- When not to use it
- FAQ
- Cheat sheet
Here is the version everyone writes first, in full:
It works. It also ships five bugs, and each one costs about a day:
- Next always advances. There is no gate. A user tabs past every required field and lands on Review with an empty object.
- A refresh is a total loss.
stepanddataare component state; ⌘R resets both. - The browser back button leaves the page. Users expect Back to mean "previous step." Yours means "goodbye."
- A screen reader is told nothing. The panel swaps, the DOM changes, and the announcement queue stays empty. Focus is still sitting on the Next button that just disappeared and got re-rendered as a different button.
- You cannot link to step 3. Support asks a customer to "go to the payment step" and has to describe the route in words.
None of those are hard individually. The problem is that they are five different subsystems, and the naive version has no seam to hang any of them on.
The honest survey, with versions checked against npm at the time of writing:
| Package | Latest | Last published | Shape |
|---|---|---|---|
react-hook-form | 7.87.0 | Aug 2026 | Form state. No opinion about steps at all. |
formik | 2.4.9 | Nov 2025 | Same — form state, no cursor. |
@stepperize/react | 7.0.0 | Jun 2026 | Headless, type-safe, actively maintained. Ships zero UI by design. |
react-step-wizard | 5.3.11 | Aug 2024 | Styled component, quiet for two years. |
react-multistep | 7.0.0 | Feb 2026 | Styled component, opinionated markup. |
react-albus | 2.0.0 | Jun 2022 | Router-flavoured wizard. Three runtime deps. |
Two of these deserve a genuine defence.
react-hook-form is the right answer to a different question. It is the best form-state library in the React ecosystem and it is not trying to be a wizard. Nothing below argues against using it — the recipes actively use it.
@stepperize/react is good, current, and well typed. If your design system already owns every pixel and you want nothing but a state machine, use it. It ships no markup on purpose, which means you write the rail, the progress bar, the buttons, and all of the ARIA tabs wiring yourself. That is a feature when you have a design system, and a week of work when you don't.
Three concrete gaps, not vibes.
The peer range that stops the install. react-multistep@7.0.0 declares peerDependencies as { "react": "18.3.1" } — an exact version, not a range. On a React 19 app, npm refuses:
You can --legacy-peer-deps past it. You are then running a component whose author has not tested your React version.
Headless means you own the accessibility. Here is the minimum the tabs pattern asks for, and none of it comes free:
That is a real day of work, and it is the part that gets cut when the sprint is tight.
Styled-but-stale means you inherit the CSS. react-albus was last published in June 2022 and still pulls in three runtime dependencies (history, hoist-non-react-statics, prop-types). A styled component that has stopped shipping is one you restyle with !important, which is the sound of a dependency that has stopped helping.
This is the section worth keeping even if you install nothing.
The mental model: a multi-step form is three independent things wearing one component's clothes.
- A cursor — which step is showing, plus the furthest step reached. Two numbers.
- Shared data — one object every step reads and writes. Not one state per step.
- A gate per step — a pure function answering one question: may the user leave this step? It returns
true, or a string that is both "no" and the reason.
Everything else is a projection of those three. The progress bar is a projection of the cursor. Persistence is serialising the data. A deep link is the cursor in the URL. Focus management is a side effect of the cursor changing.
The payoff is diagnostic. Every wizard bug now has an address:
- A lost draft is a data problem.
- A Next button that won't move is a gate problem.
- A back button that exits the page is a cursor problem.
- Steps that unlock when they shouldn't is a cursor problem too — specifically the "furthest reached" number.
Note what the gate is not: it is not a submit handler. It runs on every render, so it must be pure and cheap, and it must never set state. Get that wrong and you have an infinite render loop rather than a validation bug.
The three pieces above are exactly what react-form-wizard-component implements, and v2.0.0 landed on npm at the end of August 2026.
That is a working wizard with a progress bar, arrow-key and Home/End navigation, swipe on touch, focus movement, and live-region announcements.
Honestly, in one sentence: a styled component and a headless hook from the same package — 5.85 kB minified and brotlied, zero runtime dependencies, React 17, 18 and 19. Those size numbers are the ones CI measures with size-limit on every commit, not a bundlephobia guess; the stylesheet is another 1.38 kB.
- 📦 npm: https://www.npmjs.com/package/react-form-wizard-component
- 🐙 GitHub: https://github.com/parsajiravand/react-form-wizard
- 📚 Docs: https://react-form-wizard-component-document.netlify.app/docs
- ▶️ Live demos: https://react-form-wizard-component-document.netlify.app/docs/demos · interactive playground
The example above. Each <FormWizard.TabContent> is a step; title and icon are optional.
Key concept: the children API owns the cursor for you and nothing else. It is the right choice only while every user sees every step.
Key concept: condition is evaluated against shared data, so branching is declarative. You never write steps.filter(...) and then fix the off-by-one in the progress bar.
The contract is deliberately tiny. Return true to allow navigation, a string to block it and supply the message, false to block silently:
Two adapters produce that function from tools you already use. With Zod:
With react-hook-form, gating a step on a subset of one form's fields:
Key concept: this is what "per-step validation" should mean — one form, one schema, and a gate that only looks at its own fields. Not four forms glued together with a reducer.
Neither zod nor react-hook-form becomes a dependency. Both adapters are typed structurally: zodValidator accepts anything with a safeParse method, so Valibot and ArkType work too, and hookFormValidator reads formState.errors and nothing else — it never mutates form state, which is what keeps it safe to run on every render. Combine rules with composeValidators(...); first failure wins.
Key concept: <FormWizard /> is built on this hook. Choosing headless is not choosing a different library — the state machine is identical, you are only declining the markup and the stylesheet.
Here is the part worth knowing if you are already on 1.x.
Every prop, method, hook and adapter is unchanged. v2.0.0 is a major version because the default appearance changed, and a visual change to a component you already styled is a breaking change even when the types say otherwise. Semver's job is to warn you before the deploy, not after.
Keeping the exact v1 look is a two-line diff:
This is the most interesting decision in the release, and it generalises well beyond this package.
v1's stylesheet had no prefers-color-scheme rules at all — dark mode existed only as inline styles you hand-listed through a customDarkModeColor prop. The obvious fix is to add a prefers-color-scheme block. That fix is wrong, and here is why:
An embedded component cannot assume the OS preference describes the surface behind it. A light-only marketing page, viewed on a laptop set to dark, would render a dark wizard on a white card.
So the default (colorScheme="auto") reacts only to explicit signals from the host page: an ancestor carrying [data-theme="dark"] or .dark — which is what Tailwind, next-themes, Docusaurus and Fumadocs all set — or the darkMode prop.
If you own the whole page rather than a component inside someone else's, the media query is the right tool — I wrote about the light-dark() CSS function for exactly that case. The rule of thumb: the page may read the OS; a component should read the page.
v1 set colours as inline styles on the markers, the rail and the footer. Inline styles beat every selector you can write short of !important, which is why theming and dark mode were unreliable. In v2, all of it flows from CSS custom properties and state classes:
Your CSS wins now, with normal specificity. Each step also carries state classes — active, rfw-done, rfw-invalid — so you can style a completed or failed step directly.
And because the v1 skin moved into its own opt-in stylesheet, the default is smaller than 1.2.1 despite gaining a full dark palette: 1.38 kB brotlied, down from 1.85 kB.
A genuine bug, and my favourite kind — the one that only shows up in a real app. showErrorOnTab defaulted to !isValid, so any step carrying a validator painted its marker red on first paint. A wizard whose first question was required looked broken on load: the user is accused before they have typed a character.
The error state now waits until the user has actually tried to leave the step:
An explicit showErrorOnTab still applies immediately.
Keep the bundled skin but adopt your theme — one import, no unstyled, Tailwind v4:
Or build it entirely from utility classes:
One trap worth naming: the preset's classes live in node_modules, and Tailwind only emits classes it can see. Point it at the package or you will get an unstyled wizard and no error message:
Every class in the preset is a literal string for that reason — nothing is built by template concatenation, because `bg-${colour}-600` is invisible to a scanner. The accent reads from bg-[var(--rfw-primary)], so recolouring stays a one-line CSS change instead of a rebuild.
mode: "onChange" matters — the adapter reads formState.errors, so the errors have to be current for the gate to be accurate.
That is the passport-upload bug from the opening, closed. Data goes to web storage, the cursor goes to ?step=, and both are best-effort — private browsing and quota errors are caught, never thrown at the user. Clear it with ref.current.reset().
The rail, the progress bar, and the "is this the last step" logic all recount themselves. There is no index arithmetic anywhere in your code.
The credibility of everything above depends on this section being real.
- Your form has one step. Then it is a form. Use
<form>and react-hook-form, and stop reading. - Your design system owns every pixel and you want no CSS at all. Use
@stepperize/react, oruseWizard()from this package — both give you the state machine and nothing else. Picking the styled component and then fighting it with overrides is the worst of both. - Your flow is a real state machine — retries, async guards, server-driven transitions. Model it in XState and let the wizard be the view, or skip the wizard entirely.
conditionis a predicate, not a transition table. - You need validation to run only on final submit. The gate runs per step by design. You can pass
validate: () => trueeverywhere and check ononComplete, but at that point the wizard is only doing layout for you. - React 16. Untested. It needs
react/jsx-runtime, so 16.14+ in principle, but nobody has verified it. - React 17 under native Node ESM. It will not load — React 17 ships no
exportsfield, so Node cannot resolvereact/jsx-runtime. That is a React 17 limitation, reproducible without this package, and React 17 works fine through Vite, webpack, Next.js or CRA.
Give each step a pure predicate over the shared form data that returns true or an error message, and check it before advancing the cursor. Do not create one <form> per step — you lose cross-step values and end up reconciling four states. With this package that predicate is the validate option, and zodValidator / hookFormValidator generate it from a schema or a field subset.
Serialise the shared data object to sessionStorage (clears with the tab) or localStorage (survives a restart) on every change, and restore it on mount. Keep the step index in the URL rather than storage, so a shared link opens the right step. persist={{ key, storage }} and syncToUrl do both; write it yourself in about twenty lines if you prefer.
Yes, but the component holding the cursor must be a client component. This package ships a "use client" directive inside all three bundles, so you can import FormWizard directly from a server component without writing a wrapper. Import the stylesheet once, usually in app/layout.tsx.
Read the page, not the operating system. Look for an ancestor with [data-theme="dark"] or a .dark class — the conventions Tailwind, next-themes, Docusaurus and Fumadocs already set — and expose a prop for hosts that do it differently. Reading prefers-color-scheme inside a component means a light page on a dark-mode machine gets a dark widget on a white background.
Almost never, when hand-rolled. The ARIA tabs pattern wants tablist/tab/tabpanel roles, aria-selected, aria-controls, a roving tabIndex, arrow-key and Home/End handling, focus moved to the revealed panel, and a live region so the change is announced at all. Related reading on the same theme: the inert attribute and what <dialog> gives you for free.
Only if you styled it. The component API is identical — every prop, method, hook and adapter behaves exactly as in 1.2.x. If you never wrote CSS against the wizard, you get the new look and working dark mode with no code change. If you did, variant="legacy" plus the legacy.css import restores v1 pixel for pixel.
Everything you need to build a correct multi-step form, whichever library you use.
| Task | Code | Notes |
|---|---|---|
| Static steps | <FormWizard.TabContent title="…"> | Children API. |
| Data-driven steps | schema={{ steps: [...] }} | Wins over children when both are given. |
| Branch a step | condition: ({ data }) => … | false removes it from the rail entirely. |
| Gate a step | validate: ({ data }) => true | "message" | Pure and cheap — runs every render, must not set state. |
| Zod gate | zodValidator(schema, { pick: ["email"] }) | Any safeParse object works — Zod, Valibot, ArkType. |
| react-hook-form gate | hookFormValidator(form, { fields: [...] }) | Reads formState.errors; use mode: "onChange". |
| Chain rules | composeValidators(a, b) | First failure wins. |
| Survive a refresh | persist={{ key, storage: "session" }} | Best-effort; quota errors never throw. |
| Deep link a step | syncToUrl | Writes ?step=2. |
| Recolour | theme={{ primaryColor: "#0e6f70" }} | Emits --rfw-* custom properties. |
| Keep the v1 look | variant="legacy" + legacy.css | The only v2 migration step. |
| Dark mode | colorScheme="auto" | "system" | "light" | "dark" | auto follows the page; system follows the OS. |
| Tailwind, keep the skin | @import ".../tailwind.css" | v4 only — reads --color-*. |
| Tailwind, own the classes | unstyled classNames={tailwindPreset()} | Needs @source pointed at the package. |
| Style a finished step | classNames.stepComplete | Also .rfw-done / .rfw-invalid in CSS. |
| No markup at all | useWizard({ stepIds }) | Same state machine as the component. |
| Jump anywhere | ref.current.goToTabById("review") | Bypasses the visited-step gate. |
The whole pattern, annotated:
- A multi-step form is a cursor, shared data, and a per-step gate. Name the three and every bug has an address.
- Per-step validation means one form and a gate that picks its own fields — never four separate forms.
- The cursor belongs in the URL; the data belongs in storage. Putting both in the same place is what loses the passport upload.
- The step gate runs on every render. Keep it pure, keep it cheap, never set state in it.
- A component reads the page for dark mode. Only the page itself gets to read the OS.
That refresh on step four, at the top? persist puts the data back and syncToUrl puts the cursor back, and the user never learns it happened. The rest of the list — the gate, the focus, the announcement, the roving tabindex — is the part that was always going to take a week, whoever writes it.
What's your worst multi-step form story: the one that lost data, or the one nobody could operate with a keyboard?
🚀 Want more like this? Every guide, playground, and quiz lives on bestpractic.org — open it and sign up free so the next one finds you.
Thanks for reading! Let's stay connected:
- ⭐ GitHub — follow me and star the projects: github.com/parsajiravand
- 💬 Discord — join the frontend best-practices community: discord.gg/d9KRhuAwQ
- 📸 Instagram — frontend best practices, daily: @bestpractice___
Keep reading
One post a day, in your inbox
Each one with a runnable playground and a quiz. No pitch, no digest, unsubscribe in one click.
0 comments
Sign in to join the discussion, like comments, and save articles for later.