The navigation lifecycle
Understand how Stepperize handles each step transition.
The navigation lifecycle
Every move — next, prev, goTo, reset — runs the same pipeline. Understand
it once and validation, saving, and side effects all fall into place.
Press next() below. Then turn on reject in beforeStepChange and press it again to watch the change cancel before anything commits.
payloadnext({ data }) — pending data is preparedbeforeStepChangesees the pending data; return false to cancelcommitdata is saved and the current step changesonStepChangeruns after the move is acceptedreturns truenavigation resolves to whether it changed
The four stages
- Payload — you optionally pass data with
next({ data }); it is staged for the current step. To stage several steps at once, callstepper.data.set(...)first, then navigate. These pending values are prepared first. beforeStepChange— runs before the move. It already sees the pending values, so you can validate the data being submitted in the same call. Returnfalseto cancel. It can be async.- Commit — values are saved and the current step changes. This only happens if no guard cancelled.
onStepChange— runs after the move is accepted. Use it for analytics, persistence, side effects, and to sync a controlledstep.
The call then resolves to true if the step changed, or false if it
didn't (an edge, a blocked move, or a cancelled guard).
For forms, this is the key mental model: your form library validates fields;
beforeStepChange validates the transition.
Why payload-before-commit matters
Because beforeStepChange sees the payload, you can validate data that hasn't hit
React state yet:
const accepted = await stepper.next({ data: form.getValues() });
// inside beforeStepChange, validate() reads that same payload snapshotNo useEffect, no waiting for a re-render. Submit and validate in one action.
Lifecycle callbacks are instance-level — pass them to useStepper (or
Provider / Stepper.Root):
const stepper = checkout.useStepper({
beforeStepChange: async ({ direction, validate }) => {
if (direction === "prev") return true;
// validate() runs the step being left against this transition's data
// snapshot. Schemaless steps always succeed.
return (await validate()).success; // false cancels the move
},
onStepChange: (step) => analytics.track("step_view", { step }),
});Reading the result
next/prev/goTo/reset are always async (a guard might be). A bare call
returns a Promise — await it when you need the boolean:
// ✅ fine for buttons — fire and forget
<button onClick={() => stepper.next()}>Next</button>
// ✅ await when the result drives logic
const accepted = await stepper.next({ data: value });
if (accepted) stepper.setComplete();if (!stepper.next()) never runs — an un-awaited call is a Promise, which is
always truthy. Always await before branching on the result.
While a guard or change handler is running, isPending is true and
canPrev/canNext go conservative, so your buttons disable themselves during
async work.
Next: status vs completion.
Last updated on