Controlled vs uncontrolled
Choose where stepper state should live.
Stepperize follows the same model as a React <input>. Three pieces of state can
each be uncontrolled (Stepperize owns them) or controlled (you own them):
the current step, the flow data, and the completed list.
You mix and match freely — controlled step, uncontrolled data, and so on.
Uncontrolled (the default)
Stepperize holds the state internally. You set a starting point and let it run. This is what you want most of the time.
const stepper = checkout.useStepper({ defaultStep: "shipping" });
// Stepperize owns the current step. next()/prev() just work. ┌──────────────────────────┐
│ Stepperize │
next()│ owns current step │
──────►│ owns values │──► your UI re-renders
│ owns completed │
└──────────────────────────┘Controlled
You pass the value and a change handler. Stepperize never mutates its own copy — it calls your handler and waits for the new prop to flow back in.
const [step, setStep] = React.useState("shipping");
const stepper = checkout.useStepper({
step, // you provide the value
onStepChange: setStep, // you apply the change
}); ┌─────────────┐ onStepChange(next) ┌──────────┐
next()│ Stepperize │ ─────────────────────► │ you │
──────►│ (no internal│ │ setStep │
│ step state)│ ◄───────────────────── │ │
└─────────────┘ step={...} └──────────┘The #1 mistake: passing step without onStepChange. The stepper becomes
read-only and "won't move" — because you told it you own the state, then never
updated it. If you pass a controlled value, you must handle its change.
Try the ownership handshake
- Choose Next with both checkboxes enabled. The request and the rendered step advance together.
- Return the external step to
details, disable Apply onStepChange requests, and choose Next. The request is accepted, but the rendered step stays put because the owner did not apply it. - Disable Guard allows navigation and choose Next to see a rejected request. Then change External step: the prop changes directly, bypassing the navigation lifecycle.
The last requested step records the most recent callback; external prop changes do not invoke that callback.
Controlled step
Stepperize requests a move; the owner decides whether to apply it.
Your component
Owns step in React state.
Off: requests arrive, the prop never changes.
Stepperize
Runs the guard, then requests the move.
- details
- review
- done
Off: beforeStepChange returns false.
Rendered step: details
Last requested step: None
Result: No request yet
An accepted request still needs the owner to update its prop. The external control updates that prop directly and does not run the guard.
When to control each piece
| Control… | When |
|---|---|
step / onStepChange | The current step lives in the URL, a router, or an external store. |
data / onDataChange | Drafts live in a form store, server state, or are persisted. |
completed / onCompletedChange | Completion is derived from a workflow engine or backend. |
If none of those apply, stay uncontrolled — it's less code and fewer footguns.
Example: sync the step to the URL
const stepper = checkout.useStepper({
step: stepFromUrl,
onStepChange: (next) => navigate({ search: { step: next } }),
onInvalidStep: () => navigate({ search: { step: "shipping" }, replace: true }),
});The controlled step option accepts raw external strings. If it is unknown, the
stepper falls back to defaultStep (or the first step) and calls
onInvalidStep, so you can recover — for example by replacing the bad URL.
External step changes are authoritative and never run the
beforeStepChange guard.
Use checkout.parseStep(value) when you need to narrow an untrusted value to a
known step id before passing it to another typed API, such as goTo(id).
The same options work on Provider and Stepper.Root, so you can control a
shared instance the same way. See Sharing a stepper.
Last updated on