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.
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.
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.
View complete source
Requires React and @stepperize/react v8. Tailwind CSS supplies the optional styling. This is the same source used by the live demo.
"use client";
import { defineStepper } from "@stepperize/react";
import { useState } from "react";
const flow = defineStepper([
{ id: "details" },
{ id: "review" },
{ id: "done" },
]);
type StepId = (typeof flow.steps)[number]["id"];
const buttonClass =
"rounded-md border px-3 py-2 text-sm hover:bg-muted disabled:opacity-40";
export function ControlledDemo() {
const [step, setStep] = useState<StepId>("details");
const [applyChanges, setApplyChanges] = useState(true);
const [guardAllows, setGuardAllows] = useState(true);
const [requested, setRequested] = useState("None");
const [result, setResult] = useState("No request yet");
const stepper = flow.useStepper({
step,
beforeStepChange: () => guardAllows,
onStepChange: (id) => {
setRequested(id);
if (applyChanges) setStep(id);
},
});
return (
<section
aria-label="Controlled state demo"
className="not-prose my-6 space-y-4 rounded-xl border bg-card p-5"
>
<label className="grid gap-2 text-sm">
External step
<select
className="rounded-md border bg-background px-3 py-2"
value={step}
onChange={(event) => {
const id = flow.parseStep(event.target.value);
if (id) setStep(id);
}}
>
{flow.steps.map(({ id }) => (
<option key={id} value={id}>
{id}
</option>
))}
</select>
</label>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={applyChanges}
onChange={(event) => setApplyChanges(event.target.checked)}
/>
Apply onStepChange requests
</label>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={guardAllows}
onChange={(event) => setGuardAllows(event.target.checked)}
/>
Guard allows navigation
</label>
<div className="flex flex-wrap gap-2">
<button
type="button"
className={buttonClass}
disabled={!stepper.canPrev}
onClick={async () => setResult(JSON.stringify(await stepper.prev()))}
>
Back
</button>
<button
type="button"
className={buttonClass}
disabled={!stepper.canNext}
onClick={async () => setResult(JSON.stringify(await stepper.next()))}
>
Next
</button>
<button
type="button"
className={buttonClass}
onClick={() => {
setStep("details");
setApplyChanges(true);
setGuardAllows(true);
setRequested("None");
setResult("No request yet");
}}
>
Reset demo
</button>
</div>
<div role="status" className="space-y-2 rounded-lg bg-muted p-3 text-sm">
<p>Rendered step: {stepper.id}</p>
<p>Last requested step: {requested}</p>
<p className="break-all">Result: {result}</p>
</div>
<p className="text-sm text-muted-foreground">
An accepted request still needs the owner to update its prop. The
external control updates that prop directly and does not run the guard.
</p>
</section>
);
}
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