Local and shared state
Choose who owns a flow, with complete examples and an interactive comparison.
Local and shared state
In v8, the hook name tells you where the state lives:
| API | What it does |
|---|---|
flow.useStepper(options) | Creates independent local state on every call. |
flow.Provider | Creates state and shares it with descendants. |
flow.useStepperContext() | Reads the nearest matching provider. |
flow.useStepperContext(selector, isEqual?) | Reads and subscribes to part of that shared state. |
flow.Stepper.Root | Creates shared state and a primitive container. |
Try moving the two local flows separately. The shared example has a header, panel and controls implemented as separate components consuming one provider.
Move the outer shared checkout, then try Local checkout inside Provider and Nested shared checkout. Neither should move the outer checkout. The nested provider starts at payment and resets to payment, its own default.
Local checkout A
Shipping
Local checkout B
Shipping
Shared checkout
The heading, panel and controls are separate components sharing one Provider.
Step 1 of 3
Choose a delivery address.
Both examples below are inside the outer Provider. The local hook creates its own instance; the inner Provider creates a separate shared scope.
Local checkout inside Provider
Shipping
Nested shared checkout
Step 2 of 3
Choose a payment method.
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, type Stepper } from "@stepperize/react";
const flow = defineStepper([
{ id: "shipping", title: "Shipping" },
{ id: "payment", title: "Payment" },
{ id: "review", title: "Review" },
]);
const buttonClass =
"rounded-md border px-3 py-1.5 text-sm hover:bg-muted disabled:opacity-40";
/** Two independent owners and a shared owner rendered through separate consumers. */
export function OwnershipDemo() {
return (
<div className="not-prose my-6 space-y-4">
<div className="grid gap-3 sm:grid-cols-2">
<LocalCheckout label="Local checkout A" />
<LocalCheckout label="Local checkout B" />
</div>
<flow.Provider>
<section
aria-label="Shared checkout"
className="rounded-xl border border-primary/30 bg-primary/5 p-4"
>
<h3 className="font-semibold">Shared checkout</h3>
<p className="mb-4 text-sm text-muted-foreground">
The heading, panel and controls are separate components sharing one
Provider.
</p>
<SharedHeading />
<SharedPanel />
<SharedControls />
</section>
<div className="grid gap-3 rounded-xl border border-dashed p-4 sm:grid-cols-2">
<p className="text-sm text-muted-foreground sm:col-span-2">
Both examples below are inside the outer Provider. The local hook
creates its own instance; the inner Provider creates a separate
shared scope.
</p>
<LocalCheckout label="Local checkout inside Provider" />
<flow.Provider defaultStep="payment">
<section
aria-label="Nested shared checkout"
className="rounded-xl border p-4"
>
<h3 className="font-semibold">Nested shared checkout</h3>
<SharedHeading />
<SharedPanel />
<SharedControls />
</section>
</flow.Provider>
</div>
</flow.Provider>
</div>
);
}
function LocalCheckout({ label }: { label: string }) {
const stepper = flow.useStepper();
return (
<section aria-label={label} className="rounded-xl border p-4">
<h3 className="font-semibold">{label}</h3>
<p className="my-3" aria-live="polite">
{stepper.current.title}
</p>
<Controls stepper={stepper} />
</section>
);
}
function SharedHeading() {
const index = flow.useStepperContext((s) => s.index);
return (
<p className="text-sm text-muted-foreground">
Step {index + 1} of {flow.steps.length}
</p>
);
}
function SharedPanel() {
const stepper = flow.useStepperContext();
return (
<div className="my-3" aria-live="polite">
{stepper.match({
shipping: () => <p>Choose a delivery address.</p>,
payment: () => <p>Choose a payment method.</p>,
review: () => <p>Review your order.</p>,
})}
</div>
);
}
function SharedControls() {
const stepper = flow.useStepperContext();
return <Controls stepper={stepper} />;
}
function Controls({ stepper }: { stepper: Stepper<typeof flow.steps> }) {
return (
<div className="flex gap-2">
<button
type="button"
className={buttonClass}
disabled={!stepper.canPrev}
onClick={() => void stepper.prev()}
>
Back
</button>
<button
type="button"
className={buttonClass}
disabled={!stepper.canNext}
onClick={() => void stepper.next()}
>
Next
</button>
<button
type="button"
className={buttonClass}
onClick={() => void stepper.reset()}
>
Reset
</button>
</div>
);
}
One definition, many instances
Keep the definition outside React components. It contains static step metadata and typed APIs; it does not contain an application-wide active step.
import { defineStepper } from "@stepperize/react";
export const checkout = defineStepper([
{ id: "shipping", title: "Shipping" },
{ id: "payment", title: "Payment" },
{ id: "review", title: "Review" },
], { linear: true });Complete local example
Use this when one component owns the whole flow. Every mounted LocalCheckout has its own current step, data and completion.
"use client";
import { checkout } from "./checkout";
export function LocalCheckout() {
const stepper = checkout.useStepper();
return (
<section aria-label="Local checkout">
<h2>{stepper.current.title}</h2>
<p>Step {stepper.index + 1} of {stepper.count}</p>
{stepper.match({
shipping: () => <p>Choose a shipping address.</p>,
payment: () => <p>Choose a payment method.</p>,
review: () => <p>Review your order.</p>,
})}
<button type="button" disabled={!stepper.canPrev} onClick={() => stepper.prev()}>Back</button>
<button type="button" disabled={!stepper.canNext} onClick={() => stepper.next()}>Next</button>
<button type="button" onClick={() => stepper.reset()}>Start again</button>
</section>
);
}Complete shared example
Split the same UI into components. Put one provider above them and replace local hook calls with useStepperContext.
"use client";
import { checkout } from "./checkout";
export function SharedCheckout() {
return (
<checkout.Provider defaultStep="shipping">
<section aria-label="Shared checkout">
<Header />
<Panel />
<Actions />
</section>
</checkout.Provider>
);
}
function Header() {
const index = checkout.useStepperContext((stepper) => stepper.index);
return <p>Step {index + 1} of {checkout.steps.length}</p>;
}
function Panel() {
const stepper = checkout.useStepperContext();
return (
<div aria-live="polite">
<h2>{stepper.current.title}</h2>
{stepper.match({
shipping: () => <p>Choose a shipping address.</p>,
payment: () => <p>Choose a payment method.</p>,
review: () => <p>Review your order.</p>,
})}
</div>
);
}
function Actions() {
const stepper = checkout.useStepperContext();
return (
<div>
<button type="button" disabled={!stepper.canPrev} onClick={() => stepper.prev()}>Back</button>
<button type="button" disabled={!stepper.canNext} onClick={() => stepper.next()}>Next</button>
<button type="button" onClick={() => stepper.reset()}>Start again</button>
</div>
);
}Header subscribes only to the index, so writing unrelated data does not cause a store-driven render. The panel and controls subscribe to the full snapshot.
Use primitives with the same ownership model
Replace the Provider with a Root when you need its container and primitive presentation. Root accepts the same options and supplies the same context hook.
export function PrimitiveCheckout() {
const { Stepper } = checkout;
return (
<Stepper.Root orientation="vertical" linear>
<Header />
<Stepper.List aria-label="Checkout steps">
<Stepper.Items>
{(step) => (
<Stepper.Item key={step.id}>
<Stepper.Trigger>
<Stepper.Indicator />
<Stepper.Title>{step.title}</Stepper.Title>
</Stepper.Trigger>
<Stepper.Content step={step.id}><p>{step.title} content</p></Stepper.Content>
<Stepper.Separator />
</Stepper.Item>
)}
</Stepper.Items>
</Stepper.List>
<Actions />
</Stepper.Root>
);
}The List and Separator inherit vertical orientation. Each Root gets its own DOM identifiers, so two checkouts on one page do not share tab/panel ids.
Nested flows and missing providers
A nested provider of the same definition creates a new independent scope for its descendants. A provider from another definition leaves the outer flow available. Always import the same definition object in consumers.
Calling useStepperContext() without a matching owner throws; it does not silently create a new flow. Calling useStepper() under a Provider deliberately creates a local flow.
For a focused subscription demo, try context selectors.
Saving and completing together
The ownership choice does not change navigation. A local or shared instance uses the same atomic action:
const result = await stepper.next({ data: formValues, complete: true });
if (!result.accepted) {
// The payload and source completion were not committed.
console.log(result.reason);
}The form library owns editable fields. Stepperize stores accepted flow data for review, branching and persistence. See form patterns and navigation.
Last updated on