Stepperize v8Explore the changes

The navigation lifecycle

Understand staged data, guards, atomic commits and results.

The navigation lifecycle

Every imperative move (next, prev, goTo, reset) follows the same contract. Try an accepted or rejected transition below.

Try a real transition

  1. Enable Reject in guard, edit the draft, then choose Save and continue. The committed draft and completion stay unchanged.
  2. Disable rejection and try a name shorter than three characters: schema validation still blocks the move.
  3. Enter a valid name and continue. While validation is pending, try a duplicate request to see pending in the event log.
  4. Reset, continue again, and choose Write data while pending. The immediate write cancels the old move; its staged payload cannot overwrite the external edit.

The demo uses the actual Stepperize instance. Only the 800 ms validation delay is simulated; no data is sent to a server. The result line shows the last resolved request, and the event log preserves earlier outcomes.

Current: details · Pending: false

{
  "data": {
    "details": "Original draft"
  },
  "completed": []
}

No request yet

Events will appear here.
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.

lifecycle-viz.tsx
"use client";

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

const checkout = defineStepper(
	[
		{
			id: "details",
			title: "Details",
			schema: {
				"~standard": {
					version: 1 as const,
					vendor: "demo",
					validate: (value: unknown) =>
						typeof value === "string" && value.trim().length >= 3
							? { value }
							: { issues: [{ message: "Enter at least 3 characters." }] },
				},
			},
		},
		{ id: "review", title: "Review" },
	],
	{ defaultData: { details: "Original draft" } },
);
const buttonClass =
	"rounded-md border px-3 py-2 text-sm hover:bg-muted disabled:opacity-40";

// The timer only simulates server latency. Stepperize owns the transition.
function delay(signal: AbortSignal) {
	return new Promise<void>((resolve) => {
		if (signal.aborted) return resolve();
		const finish = () => {
			clearTimeout(timer);
			signal.removeEventListener("abort", finish);
			resolve();
		};
		const timer = setTimeout(finish, 800);
		signal.addEventListener("abort", finish, { once: true });
	});
}

export function LifecycleViz() {
	const [draft, setDraft] = useState("Ada");
	const [reject, setReject] = useState(false);
	const [events, setEvents] = useState<string[]>([]);
	const [outcome, setOutcome] = useState("No request yet");
	const record = (event: string) =>
		setEvents((previous) => [...previous.slice(-7), event]);
	const stepper = checkout.useStepper({
		beforeStepChange: async ({ direction, data, validate, signal }) => {
			if (direction === "reset") return true;
			record(`Guard received staged data: ${JSON.stringify(data.details)}`);
			await delay(signal);
			if (signal.aborted) return false;
			const result = await validate();
			const allowed = result.success && !reject;
			record(
				allowed
					? "Guard accepted"
					: "Guard rejected: invalid name or rejection enabled",
			);
			return allowed;
		},
		onStepChange: (id) => record(`onStepChange: ${id}`),
	});

	async function move() {
		try {
			const result = await stepper.next({ data: draft, complete: true });
			setOutcome(JSON.stringify(result));
			record(`Result: ${JSON.stringify(result)}`);
		} catch (error) {
			setOutcome(error instanceof Error ? error.message : "Unexpected error");
		}
	}

	return (
		<section
			aria-label="Navigation lifecycle demo"
			className="not-prose my-6 space-y-4 rounded-xl border bg-card p-5"
		>
			<label className="grid gap-2 text-sm">
				Draft name
				<input
					className="rounded-md border bg-background px-3 py-2"
					value={draft}
					disabled={stepper.id !== "details" || stepper.isPending}
					onChange={(event) => setDraft(event.target.value)}
				/>
			</label>
			<label className="flex items-center gap-2 text-sm">
				<input
					type="checkbox"
					checked={reject}
					disabled={stepper.isPending}
					onChange={(event) => setReject(event.target.checked)}
				/>{" "}
				Reject in guard
			</label>
			<div className="flex flex-wrap gap-2">
				<button
					type="button"
					className={buttonClass}
					disabled={!stepper.canNext}
					onClick={() => void move()}
				>
					{stepper.isPending ? "Validating…" : "Save and continue"}
				</button>
				<button
					type="button"
					className={buttonClass}
					disabled={!stepper.isPending}
					onClick={() => void move()}
				>
					Try duplicate request
				</button>
				<button
					type="button"
					className={buttonClass}
					disabled={!stepper.isPending}
					onClick={() => {
						stepper.data.set("details", "External edit");
						record("Immediate external write cancelled the pending move");
					}}
				>
					Write data while pending
				</button>
				<button
					type="button"
					className={buttonClass}
					disabled={stepper.isPending}
					onClick={async () => {
						await stepper.reset();
						setOutcome("Reset to mount-time defaults");
						setEvents([]);
					}}
				>
					Reset demo
				</button>
			</div>
			<p role="status">
				Current: {stepper.id} · Pending: {String(stepper.isPending)}
			</p>
			<section aria-label="Committed state">
				<pre className="overflow-auto rounded-lg bg-muted p-3 text-xs">
					{JSON.stringify(
						{ data: stepper.data.all(), completed: stepper.completed },
						null,
						2,
					)}
				</pre>
			</section>
			<p
				role="status"
				className="break-all text-sm"
				aria-label="Navigation result"
			>
				{outcome}
			</p>
			<pre
				role="log"
				aria-label="Navigation events"
				className="whitespace-pre-wrap rounded-lg border p-3 text-xs"
			>
				{events.join("\n") || "Events will appear here."}
			</pre>
		</section>
	);
}

The sequence

  1. Eligibility: reject an overlapping request, boundary, unknown id, policy violation or no-op.
  2. Stage: prepare source data and optional source completion. Reset instead prepares mount-time defaults, honoring its keep options.
  3. Guard: call beforeStepChange(context). It sees the staged data. Return false to reject; an async guard sets isPending.
  4. Commit: update uncontrolled step, data and completion together. Controlled pieces are requested through their matching callbacks.
  5. Notify: call onStepChange(id, context) and resolve { accepted: true, from, to }.

Rejected navigation resolves { accepted: false, reason }. The staged payload and completion are discarded. Direct data.set calls are immediate writes, so they are not rolled back by a later rejected navigation.

Submit data and complete the source

const result = await stepper.next({ data: formValues, complete: true });
if (!result.accepted) {
  console.log(result.reason);
  return;
}

context.validate() checks the source step's schema against that staged data, without waiting for a React render. It does not automatically replace draft data with transformed schema output.

Configure callbacks on the owner: local useStepper, shared Provider, or Stepper.Root. Consumers use useStepperContext() to access the same instance.

Pending and cancellation

An async guard holds isPending until it settles or is cancelled. Another navigation returns pending. Unmounting or updating authoritative state cancels the old transition; context.signal lets your asynchronous work observe cancellation.

onStepChange is a synchronous notification callback; asynchronous work that must block navigation belongs in beforeStepChange. Errors thrown by callbacks reject the navigation promise. Effects on stepper.id can observe rendered state, including external controlled changes that do not run navigation callbacks.

Both a Promise and a result object are truthy. Use const result = await stepper.next() and check result.accepted.

See all outcomes and reset options, then status vs completion.

Edit on GitHub

Last updated on

On this page