react-multistep: a headless wizard that owns only the step state
React multistep wizard component
At a glance
- What is it?
- react-multistep v8 is a headless React component for multi-step forms. It keeps step state, validation gating and navigation, while the application renders its own chrome and each step reports its own validity through a hook. The release is ESM only, targets React 19.2, and drops the injected prop that earlier versions relied on.
- Who is it for?
- Adopt react-multistep v8 if you are building a wizard in React 19.2 on a bundler that supports ES modules, and you want validation gating and step navigation without writing the state machine yourself. Skip it if your steps must survive a round trip, since the default keepMounted mode is what preserves their state, if you need CommonJS support, which the package does not offer, or if you are still on React 18, since the peer dependency is react ^19.2.0.
- Can I use it commercially?
- Yes. MIT is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
- Is it still maintained?
- Yes. The repository last received commits 140 days ago.
- What is it written in?
- Mainly TypeScript, according to GitHub's language statistics.
Answers come from the project's GitHub data, last synced on October 23, 2026, and from our analysis. They are not legal advice.
Editorial analysis
The component renders nothing you did not write
react-multistep started as a styled wizard with step indicators and buttons included. Version 8 is a headless rewrite, and the README describes the split plainly: the component owns step state, validation gating and navigation, while you render the UI.
That means there is no built-in progress bar, no next and back buttons, no chrome of any kind. You provide the surrounding chrome, the step indicators and the navigation buttons yourself, and the component sits underneath it.
The reason the rewrite is worth understanding is that the ownership boundary moved. Earlier versions injected a prop into your steps, and the injected signalParent prop is gone in v8. A step now reports its own validity by calling a hook from inside its own subtree, and no prop is injected into your steps. Your step components stay yours, which matters when a step is a shared form component used outside a wizard.
The other half of the rewrite is the build. v8 ships ESM only, targets React 19.2 or newer, and rebuilds the step contract around the useReportValidity hook and a structured StepValidity type. The README points at a migration section and at CHANGELOG.md for the full list of breaking changes.
Validity travels upward through a stable callback
The step contract is the core of the new design. A step calls useReportValidity and receives a stable callback. The step then reports a StepValidity value whenever its validity changes, typically from an effect, and MultiStep uses those reports to gate forward navigation.
The type is a discriminated union with three states:
type StepValidity =
| { status: "valid" }
| { status: "invalid"; message?: string; errors?: Record<string, string> }
| { status: "pending" };The detail that catches people is the third state. Every step starts at { status: "pending" }, and pending is not valid, so the forward gate stays blocked until a step explicitly reports { status: "valid" }. An invalid report can carry a message and a record of per-field errors. Pending is meant for a decision that is still in flight, for example async validation, and it blocks navigation in the meantime.
A minimal step looks like this:
import { useReportValidity } from "react-multistep";
function AccountStep() {
const report = useReportValidity();
useEffect(() => {
report({ status: "valid" });
}, [report /* , ...your validity deps */]);
// ...
}Because the callback identity is stable, the effect dependency array does not retrigger on every render, and the comment in the example is the pattern to copy: depend on the values that actually change your validity, not on the report function.
Render mode decides whether a step remembers you
The mode prop is the most consequential choice in the API, and the default is not the intuitive one.
In keepMounted mode, which is the default, every step stays mounted at all times. Inactive steps are wrapped in a hidden container using the hidden attribute and display: none, so they leave the visual and accessibility tree while remaining in the DOM. Because they stay mounted, each step's local state survives navigating away and back, and each step's validity effect runs from mount. The README gives the reason: that makes per-step validity honest, because the forward gate can know whether step 3 is valid before the user ever lands on it, instead of treating an unvisited step as a blank.
In unmount mode, only the active step renders. Inactive subtrees are unmounted, so their state and any validity they reported are discarded when you leave them. The README recommends this when steps are expensive, or when you specifically want each step to start fresh.
The choice is not cosmetic. Switching to unmount to save memory on expensive steps also removes the knowledge that the gate had about later steps, because an unmounted step never reported its validity. Code that jumps forward to step 4 will find the gate closed in unmount mode for reasons that have nothing to do with the user's input.
Nine props, and one of them can veto a step change
The prop table is compact for what it does. children takes the steps, one child per step, and at least one is required. activeStep makes the component controlled and pairs with onStepChange, while defaultStep sets the starting index in uncontrolled mode, where 0 is the default.
The interesting one is beforeStepChange. It is a guard that is awaited before a step change commits, and it can return a boolean or a promise of one, or void. Returning false, or throwing or rejecting, vetoes the change. That is how you put a confirmation dialog, a save-to-server call or a dirty-check prompt in front of a navigation event without reimplementing the step machine.
The remaining props are onValidationError, which is called with the first invalid step index when a forward jump is gated, and onComplete, which fires when complete() succeeds on the last step. focusOnStepChange controls where focus lands after a change, with panel, heading or false, and panel is the default.
Forward navigation covers more than the Next button. Jumping ahead and the programmatic next() and goToStep(target) calls, where the target is greater than the active index, all go through the same gate, and every step between the current one and the target has to be valid.
An ESM only install with a React peer dependency
Installing is the familiar one-liner:
npm install react-multistepThe peer dependency is react ^19.2, so React 19.2 or newer has to be present in the application. The package is ESM only, which means it has to be imported from an ESM context such as a modern bundler or Node with type set to module, and CommonJS is not supported. The import itself is:
import MultiStep from "react-multistep";The package manifest makes the shape of the distribution clear. The type field is module, the main and module entries both point at the built index file, and the exports map exposes the package root plus three style entry points for the main stylesheet, the design tokens and the chrome stylesheet. The engines field requires Node 20 or newer, and the author field points at the maintainer.
Style files are published as side effects, which is what lets a bundler that understands the exports map pick up the CSS when you import the styles path.
Older major versions remain available for teams that need them. The README describes v6.x as the headless rewrite, still installable, with the note to pin 6.1.0 if you need the old react-multistep/server export, and v5.x is in maintenance mode on a v5.x.x branch. That matters for anyone whose build pipeline still expects CommonJS.
The chrome has to live inside the component's context
Because the library is headless, the navigation controls are code you write, and that code has to be positioned correctly.
The README is explicit that the chrome reads wizard state and drives navigation through hooks, and that it must render inside a MultiStep child, because those hooks read the context that MultiStep creates. Put the buttons outside the MultiStep element and the hooks have nothing to read.
The example in the documentation shows the composition: a MultiStep element with mode set to unmount, step components as children, and a WizardChrome wrapper defined by the application, which contains the step's own input. The chrome is described as accessible, and the wizard pattern section of the documentation carries a fully wired version of it.
This is the main ergonomic cost of a headless component. There is no ready-made indicator to copy, so the accessibility work of focus management, labelling and step announcements becomes the application's job. The library gives you focusOnStepChange as a lever, and the ability to know whether a step is empty without iterating it, but the semantics of the progress indicator are yours to write.
Version 8 on master, tags a release behind
The repository and the release list do not fully agree, and that is worth knowing before you plan an upgrade.
The README states the current version as v8.0.0, and the package manifest in the repository carries version 8.0.0 as well, with the description describing a responsive, headless ReactJS multistep form component. The newest tagged GitHub release, however, is v7.0.0 from 2026-02-28, before that v6.1.0 from 2025-09-29 and v6.0.0 from 2024-01-27. The last push was on 2026-06-05.
So the v8 work is in the repository and in the package manifest, while the tags stop at v7. In practice that means installing from the registry gives you the v7 line, and the v8 API described in the README has to come from the repository. That is a meaningful difference for anyone reading the documentation and then running npm install, because the useReportValidity contract and the removed signalParent prop belong to the version you cannot install from the registry.
The rest of the repository is a serious TypeScript project. There is a test/ directory with a separate tsconfig.test.json, jsdom for tests, esbuild for the build, eslint with the TypeScript and React plugins, Prettier with its own config, and a .github directory for workflows. A client-side example lives under examples/client-side/. On the process side there is CHANGELOG.md, RELEASE_PLAN.md, ROADMAP.md, SECURITY.md and a CLAUDE.md for assistant tooling. The licence is MIT.
Editorial conclusion
Adopt react-multistep v8 if you are building a wizard in React 19.2 on a bundler that supports ES modules, and you want validation gating and step navigation without writing the state machine yourself. Skip it if your steps must survive a round trip, since the default keepMounted mode is what preserves their state, if you need CommonJS support, which the package does not offer, or if you are still on React 18, since the peer dependency is react ^19.2.0. Before upgrading from v7, read the migration notes in the README, because the step contract was rebuilt around useReportValidity and StepValidity and the signalParent prop is gone. The last push was on 2026-06-05 and the newest tagged GitHub release is v7.0.0 from 2026-02-28, while the package version in the repository is 8.0.0.
Frequently asked questions
How does a step tell react-multistep that it is valid?
It calls the useReportValidity hook from inside its own subtree and reports a StepValidity value when the validity changes, usually from an effect. The callback is stable, so it does not need to be a dependency that retriggers the effect.
What is the difference between keepMounted and unmount in react-multistep?
keepMounted, the default, keeps every step in the DOM with inactive ones hidden, so their state and validity survive navigation and the forward gate knows about unvisited steps. unmount renders only the active step, discarding state and reported validity, and suits expensive steps or steps that should start fresh.
Can I stop a step change in react-multistep?
Yes. The beforeStepChange prop is awaited before a step change commits, and returning false, or throwing or rejecting, vetoes the change. It can be async, which is what allows a confirmation dialog or a save call in front of navigation.
Is react-multistep compatible with CommonJS?
No. The package is ESM only and the README states that CommonJS is not supported, so it has to be imported from an ESM context such as a modern bundler or Node with type set to module. The v6.x line is still installable if you need the old react-multistep/server export.
Official sources
Add this badge to your README
If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.
[](https://hysenlabs.com/projects/srdjan-react-multistep)Was this useful?