Stepperize v8Explore the changes
Stepperize

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

  1. Choose Next with both checkboxes enabled. The request and the rendered step advance together.
  2. 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.
  3. 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.

  1. details
  2. review
  3. 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 / onStepChangeThe current step lives in the URL, a router, or an external store.
data / onDataChangeDrafts live in a form store, server state, or are persisted.
completed / onCompletedChangeCompletion 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.

Edit on GitHub

Last updated on

On this page