Stepperize v8Explore the changes

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

  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.

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.

controlled-demo.tsx
"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 / 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