Stepperize v8Explore the changes

Navigation

Save, complete, guard, branch and reset with consistent outcomes.

Navigation

The four navigation methods return a promise with a discriminated result. Await the result when the next action depends on whether the move was accepted.

const result = await stepper.next({ data: formValues, complete: true });
if (!result.accepted) {
  console.log(result.reason);
  return;
}
console.log(`Moved from ${result.from} to ${result.to}`);
MethodBehavior
next(payload?)Move one step forward.
prev(payload?)Move one step backward.
goTo(id, options?)Request a known target, respecting linear policy.
reset(options?)Restore mount-time step, data and completion.

next, prev, and goTo accept { data?, complete? }. Data belongs to the source step. complete: true marks that source complete, not the destination. Both changes commit together only if the guard accepts.

data.set and data.update commit immediately, independently of a later navigation result. Use a navigation payload when saving and moving must succeed together.

Read the result

type NavigationResult<Id extends string = string> =
  | { accepted: true; from: Id; to: Id }
  | { accepted: false; reason:
      | "guard" | "policy" | "pending" | "boundary"
      | "same-step" | "invalid-step" | "cancelled" };
ReasonMeaning
guardbeforeStepChange returned false.
policyThe target skips ahead while linear policy is enabled.
pendingAnother navigation owns the transition gate.
boundaryThere is no previous or next step.
same-stepThe request changes neither the step nor data/completion.
invalid-stepAn unknown id reached navigation at runtime.
cancelledThe owner unmounted or state changed while a guard was pending.

An accepted same-step request can still save a payload or restore defaults. In controlled mode, acceptance means the callbacks requested the new state; the owner must apply it. Exceptions from user callbacks reject the promise and should be handled by the application.

Linear policy and branching

linear: false allows any known target. With linear: true, previous steps, the current step and the immediate next step are eligible. This rule applies to goTo, canGoTo, triggers and list keyboard navigation.

await stepper.goTo("review"); // follows policy
await stepper.goTo("review", { bypassPolicy: true }); // deliberate branch

bypassPolicy never skips beforeStepChange. Completion is independent of policy: linear mode does not automatically require completion or validate a form.

Try policy and guard rejection

At the initial step, choose Go to review: the result is policy, and the guard has not run. Choose Bypass policy to reach review. Reset, disable Guard allows navigation, and bypass again: the result is now guard.

Current: details · canGoTo("review"): false

No request yet

Guard calls: 0

The first button intentionally stays enabled so you can inspect a policy rejection. Bypassing policy still runs 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.

policy-demo.tsx
"use client";

import { defineStepper } from "@stepperize/react";
import { useState } from "react";

const flow = defineStepper(
	[{ id: "details" }, { id: "payment" }, { id: "review" }],
	{ linear: true },
);
const buttonClass =
	"rounded-md border px-3 py-2 text-sm hover:bg-muted disabled:opacity-40";

export function PolicyDemo() {
	const [guardAllows, setGuardAllows] = useState(true);
	const [result, setResult] = useState("No request yet");
	const [guardCalls, setGuardCalls] = useState(0);
	const stepper = flow.useStepper({
		beforeStepChange: ({ direction }) => {
			if (direction === "reset") return true;
			setGuardCalls((count) => count + 1);
			return guardAllows;
		},
	});
	return (
		<section
			aria-label="Linear policy demo"
			className="not-prose my-6 space-y-4 rounded-xl border bg-card p-5"
		>
			<label className="flex items-center gap-2 text-sm">
				<input
					type="checkbox"
					checked={guardAllows}
					onChange={(event) => setGuardAllows(event.target.checked)}
				/>
				Guard allows navigation
			</label>
			<p role="status">
				Current: {stepper.id} · canGoTo("review"):{" "}
				{String(stepper.canGoTo("review"))}
			</p>
			<div className="flex flex-wrap gap-2">
				<button
					type="button"
					className={buttonClass}
					disabled={stepper.isPending}
					onClick={async () =>
						setResult(JSON.stringify(await stepper.goTo("review")))
					}
				>
					Go to review
				</button>
				<button
					type="button"
					className={buttonClass}
					disabled={stepper.isPending}
					onClick={async () =>
						setResult(
							JSON.stringify(
								await stepper.goTo("review", { bypassPolicy: true }),
							),
						)
					}
				>
					Bypass policy
				</button>
				<button
					type="button"
					className={buttonClass}
					disabled={stepper.isPending}
					onClick={async () => {
						await stepper.reset();
						setGuardCalls(0);
						setResult("No request yet");
					}}
				>
					Reset demo
				</button>
			</div>
			<p role="status" className="break-all text-sm" aria-label="Policy result">
				{result}
			</p>
			<p className="text-sm">Guard calls: {guardCalls}</p>
			<p className="text-sm text-muted-foreground">
				The first button intentionally stays enabled so you can inspect a policy
				rejection. Bypassing policy still runs the guard.
			</p>
		</section>
	);
}

Guard a transition

Configure the guard on useStepper, Provider, or Stepper.Root:

const stepper = checkout.useStepper({
  beforeStepChange: async ({ fromIndex, toIndex, direction, validate }) => {
    if (direction === "reset" || toIndex <= fromIndex) return true;
    return (await validate()).success;
  },
});

This assumes the steps have schemas. validate() checks the step being left against the staged payload. Steps without schemas always succeed. Use your form library to display errors, or render result.issues from validation.

Return false to block; true or undefined allows the move. External controlled prop changes are authoritative and do not run the guard.

Async work and cancellation

While an async guard is pending, isPending is true and navigation affordances disable themselves. A synchronous gate also rejects duplicate calls in the same event. Sequential awaited navigation remains supported.

<button type="button" disabled={!stepper.canNext} onClick={() => stepper.next()}>
  {stepper.isPending ? "Saving…" : "Continue"}
</button>

Use the guard's signal with cancellable work:

beforeStepChange: async ({ data, signal }) => {
  const response = await fetch("/api/check-draft", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(data),
    signal,
  });
  return response.ok;
}

This endpoint is application code. Unmounting, changing controlled state or writing data/completion during a pending guard cancels the old transition and discards its payload. A later completion of that old work cannot move the flow. Cancellation cannot undo external side effects already performed by a server.

Reset deliberately

await stepper.reset();
await stepper.reset({ keepData: true });
await stepper.reset({ keepData: true, keepCompleted: true });
stepper.data.clear();
stepper.data.reset();

reset() restores the defaultStep, defaultData and defaultCompleted captured at mount, including definition defaults. It runs the guard. The keep options preserve their selected current values. data.clear() empties data; data.reset() restores default data without navigation.

Compare reset options

Edit the saved draft and choose Complete details and advance before each comparison. Then try Reset everything, Keep data, Keep completion, and Keep both. Compare the before/after panels: the step always returns to details, while the selected values are preserved.

The starting draft is deliberately non-empty. Restoring defaults is different from clearing data. This demo writes directly to stepper.data; the draft is already committed before navigation.

Before reset

Change the draft, complete a step, then choose a reset.

Current state

{
  "step": "details",
  "data": {
    "details": "Initial draft"
  },
  "completed": []
}

Reset restores the initial draft. It does not necessarily empty the data. Every option returns to the initial step.

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.

reset-demo.tsx
"use client";

import { defineStepper, type ResetOptions } from "@stepperize/react";
import { useState } from "react";

const flow = defineStepper(
	[
		{ id: "details", title: "Details" },
		{ id: "review", title: "Review" },
	],
	{ defaultData: { details: "Initial draft" } },
);
const buttonClass =
	"rounded-md border px-3 py-2 text-sm hover:bg-muted disabled:opacity-40";

export function ResetDemo() {
	const stepper = flow.useStepper();
	const [before, setBefore] = useState(
		"Change the draft, complete a step, then choose a reset.",
	);
	const snapshot = JSON.stringify(
		{
			step: stepper.id,
			data: stepper.data.all(),
			completed: stepper.completed,
		},
		null,
		2,
	);
	async function reset(options?: ResetOptions) {
		setBefore(snapshot);
		await stepper.reset(options);
	}
	return (
		<section
			aria-label="Reset demo"
			className="not-prose my-6 space-y-4 rounded-xl border bg-card p-5"
		>
			<label className="grid gap-2 text-sm">
				Saved draft
				<input
					className="rounded-md border bg-background px-3 py-2"
					value={String(stepper.data.get("details") ?? "")}
					onChange={(event) => stepper.data.set("details", event.target.value)}
				/>
			</label>
			<button
				type="button"
				className={buttonClass}
				disabled={!stepper.canNext}
				onClick={() => void stepper.next({ complete: true })}
			>
				Complete details and advance
			</button>
			<div className="flex flex-wrap gap-2">
				<button
					type="button"
					className={buttonClass}
					onClick={() => void reset()}
				>
					Reset everything
				</button>
				<button
					type="button"
					className={buttonClass}
					onClick={() => void reset({ keepData: true })}
				>
					Keep data
				</button>
				<button
					type="button"
					className={buttonClass}
					onClick={() => void reset({ keepCompleted: true })}
				>
					Keep completion
				</button>
				<button
					type="button"
					className={buttonClass}
					onClick={() => void reset({ keepData: true, keepCompleted: true })}
				>
					Keep both
				</button>
			</div>
			<div className="grid gap-3 sm:grid-cols-2">
				<div>
					<h3 className="mb-2 text-sm font-medium">Before reset</h3>
					<section aria-label="Before reset">
						<pre className="overflow-auto whitespace-pre-wrap rounded-lg bg-muted p-3 text-xs">
							{before}
						</pre>
					</section>
				</div>
				<div>
					<h3 className="mb-2 text-sm font-medium">Current state</h3>
					<section aria-label="After reset">
						<pre className="overflow-auto rounded-lg bg-muted p-3 text-xs">
							{snapshot}
						</pre>
					</section>
				</div>
			</div>
			<p className="text-sm text-muted-foreground">
				Reset restores the initial draft. It does not necessarily empty the
				data. Every option returns to the initial step.
			</p>
		</section>
	);
}

Final submission

next() at the last step returns boundary. Render your own final action: validate/submit the complete flow, handle application errors, then call setComplete(stepper.id) or show a success view. A stepper does not submit a form or perform a purchase on its own.

Next: form patterns.

Edit on GitHub

Last updated on

On this page