React Hook Form

Build a checkout flow with React Hook Form.

React Hook Form

This is the core patterns mapped onto React Hook Form. The flow, the responsibilities, and the hand-off are identical to the TanStack Form and Conform versions — only the field syntax changes.

npm install react-hook-form @hookform/resolvers

Setup

One shared definition. RHF owns each step's fields and their validation; Stepperize owns the flow. With form-driven navigation (Pattern 8) the RHF resolver validates on submit before stepper.next() runs, so the form library is the validation source of truth — you don't need stepper.validate() here.

import { defineStepper } from "@stepperize/react";

export const checkout = defineStepper(
  [
    { id: "personal", title: "Personal", schema: personalSchema },
    { id: "shipping", title: "Shipping", schema: shippingSchema },
    { id: "payment", title: "Payment", schema: paymentSchema },
    { id: "review", title: "Review" },
  ],
  { linear: true },
);

The per-step schema types stepper.data and powers the RHF resolver. You can also add a library-agnostic beforeStepChange guard (ctx.validate()) as a safety net for non-form navigation paths, but with form-driven navigation it is optional — see Schema & validate().

A step form (save + seed)

resolver gives RHF field-level validation. defaultValues seeds from the saved draft (Pattern 2). The submit handler saves and advances (Pattern 1).

import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";

function PersonalStep() {
  const stepper = checkout.useStepper();

  const form = useForm<PersonalValues>({
    resolver: zodResolver(personalSchema),
    defaultValues: stepper.data.get("personal") ?? { name: "", email: "" },
  });

  const onSubmit = form.handleSubmit(async (data) => {
    const moved = await stepper.next({ data: data });
    if (moved) stepper.setComplete();
  });

  return (
    <form onSubmit={onSubmit}>
      <input {...form.register("name")} />
      {form.formState.errors.name && <p role="alert">{form.formState.errors.name.message}</p>}

      <input {...form.register("email")} />
      {form.formState.errors.email && <p role="alert">{form.formState.errors.email.message}</p>}

      <button type="submit">{stepper.isLast ? "Place order" : "Next"}</button>
    </form>
  );
}

The submit button submits the form; RHF validates; on success it saves the value into Stepperize and moves. Field errors stop the submit path, so an invalid step never advances. This is Pattern 8 — form-driven navigation.

Want RHF's local form state to stay mounted instead of reseeding from drafts on every return? Wrap each step panel with React Activity and keep this same Stepperize navigation.

Review and edit

The review step is the same in every implementation — it's pure Stepperize, no form library. Read data.all(); "edit" is goTo(id), and the draft reopens the RHF form pre-filled via defaultValues.

function ReviewStep() {
  const stepper = checkout.useStepper();
  const all = stepper.data.all();

  return (
    <>
      <Summary section="Personal" data={all.personal} onEdit={() => stepper.goTo("personal")} />
      <Summary section="Shipping" data={all.shipping} onEdit={() => stepper.goTo("shipping")} />
      <Summary section="Payment"  data={all.payment}  onEdit={() => stepper.goTo("payment")} />
      <button type="button" onClick={submitOrder}>Place order</button>
    </>
  );
}

async function submitOrder() {
  await api.createOrder(checkout /* instance */.data.all());
}

Render the active step

function Checkout() {
  // One Provider so every step component AND the renderer below share the same
  // instance. Without it, each checkout.useStepper() call makes its own stepper
  // and navigation in a step would never update what is rendered here.
  return (
    <checkout.Provider>
      <CheckoutFlow />
    </checkout.Provider>
  );
}

function CheckoutFlow() {
  const stepper = checkout.useStepper();
  return stepper.match({
    personal: () => <PersonalStep />,
    shipping: () => <ShippingStep />,
    payment: () => <PaymentStep />,
    review: () => <ReviewStep />,
  });
}

How RHF maps to the patterns

PatternReact Hook Form
Seed from draftuseForm({ defaultValues: stepper.data.get(id) })
Field validationresolver: zodResolver(schema)
Save + advancehandleSubmit(async (data) => stepper.next({ data: data }))
Navigation gate (optional)beforeStepChange on the Provider/instance
Reviewstepper.data.all() (no RHF)
Edit previousstepper.goTo(id) → form re-seeds from draft

RHF keeps each step's form unmounted when inactive (only the active step renders). The draft in Stepperize is the source of truth across mounts — RHF state is per-step and ephemeral, exactly as intended.

Compare with TanStack Form and Conform.

Edit on GitHub

Last updated on

On this page