Stepperize v8Explore the changes

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:

APIWhat it does
flow.useStepper(options)Creates independent local state on every call.
flow.ProviderCreates 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.RootCreates 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.

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

checkout.ts
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.

local-checkout.tsx
"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.

shared-checkout.tsx
"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.

Edit on GitHub

Last updated on

On this page