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
- Enable Reject in guard, edit the draft, then choose Save and continue. The committed draft and completion stay unchanged.
- Disable rejection and try a name shorter than three characters: schema validation still blocks the move.
- Enter a valid name and continue. While validation is pending, try a duplicate request to see
pendingin the event log. - 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.
"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
- Eligibility: reject an overlapping request, boundary, unknown id, policy violation or no-op.
- Stage: prepare source data and optional source completion. Reset instead prepares mount-time defaults, honoring its keep options.
- Guard: call
beforeStepChange(context). It sees the staged data. Returnfalseto reject; an async guard setsisPending. - Commit: update uncontrolled step, data and completion together. Controlled pieces are requested through their matching callbacks.
- 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.
Last updated on