adapter
React Hook Form
BetaUse 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
adapterValues stay in React Hook Form while each step validates before moving forward.
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.
"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.
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.