Search documentation

Search Stepper docs, API, guides, and release notes.

adapter

React Hook Form

Beta

Use React Hook Form as the state layer for persistent multi-step inputs while Stepper owns progress and navigation.

Adapter role

React Hook Form should own field values and form lifecycle. Stepper should only receive the active step and expose the next/previous controls.

Form
field state
Keeps values mounted across steps and prepares the order details.
Stepper
progress state
Reflects where the user is and moves only when the adapter allows it.
Checkout
final order
Submits one validated order instead of scattered step-local state.

Preview

A small checkout adapter with real form state, step validation, and an order summary that survives step changes.

Persistent checkout

adapter

Values stay in React Hook Form while each step validates before moving forward.

Contact details

Add the receipt email before moving into delivery details.

Used for the order receipt and delivery updates.

Checkout details stay saved while the customer moves through the flow.

State model

Keep Stepper controlled by local flow state, and keep the form provider above the step content so values are not lost when panels unmount.

checkout-form.tsx
Copy code is available in the top right.
"use client";

import * as React from "react";
import { FormProvider, useForm, type FieldPath } from "react-hook-form";

import {
  Stepper,
  StepperContent,
  StepperItem,
  StepperList,
  StepperNext,
  StepperPrevious,
} from "@/components/ui/stepper";

type StepValue = "contact" | "shipping" | "payment";

type CheckoutValues = {
  email: string;
  shippingAddress: string;
  city: string;
  cardNumber: string;
};

const steps = [
  { value: "contact" },
  { value: "shipping" },
  { value: "payment" },
] as const;

const fieldsByStep = {
  contact: ["email"],
  shipping: ["shippingAddress", "city"],
  payment: ["cardNumber"],
} satisfies Record<StepValue, FieldPath<CheckoutValues>[]>;

export function CheckoutForm() {
  const [step, setStep] = React.useState<StepValue>("contact");
  const [completed, setCompleted] = React.useState<
    Partial<Record<StepValue, boolean>>
  >({});
  const form = useForm<CheckoutValues>({
    defaultValues: {
      email: "",
      shippingAddress: "",
      city: "",
      cardNumber: "",
    },
  });

  async function canContinue() {
    const isValid = await form.trigger(fieldsByStep[step], {
      shouldFocus: true,
    });

    if (isValid) {
      setCompleted((current) => ({ ...current, [step]: true }));
    }

    return isValid;
  }

  async function submitCheckout(values: CheckoutValues) {
    await fetch("/api/checkout", {
      method: "POST",
      body: JSON.stringify(values),
    });
  }

  return (
    <FormProvider {...form}>
      <form onSubmit={form.handleSubmit(submitCheckout)}>
        <Stepper value={step} onValueChange={setStep} steps={steps}>
          <StepperList>
            <StepperItem value="contact" completed={completed.contact}>
              Contact
            </StepperItem>
            <StepperItem
              value="shipping"
              completed={completed.shipping}
              disabled={!completed.contact}
            >
              Shipping
            </StepperItem>
            <StepperItem value="payment" disabled={!completed.shipping}>
              Payment
            </StepperItem>
          </StepperList>

          <StepperContent value="contact" keepMounted>
            {/* Email field */}
          </StepperContent>
          <StepperContent value="shipping" keepMounted>
            {/* Address fields */}
          </StepperContent>
          <StepperContent value="payment" keepMounted>
            {/* Payment fields */}
          </StepperContent>

          <div className="flex justify-end gap-2">
            <StepperPrevious />
            {step === "payment" ? (
              <button type="submit">Place order</button>
            ) : (
              <StepperNext onBeforeNext={canContinue} />
            )}
          </div>
        </Stepper>
      </form>
    </FormProvider>
  );
}

Validation boundary

Use onBeforeNext for step-level validation. The guard can be async, so it works with React Hook Form trigger, remote checks, and save-before-continue flows.

step-validation.ts
Copy code is available in the top right.
async function canContinue() {
const fields = fieldsByStep[step];
const isValid = await form.trigger(fields);

return isValid;
}

Production note

Use keepMounted for fields that must preserve local state, or keep the field state in React Hook Form with stable defaultValues. Place the order from the form, not from each StepperContent panel.