Controlled vs uncontrolled

Choose where stepper state should live.

Controlled vs uncontrolled

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.

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