TanStack Form

Build a checkout flow with TanStack Form.

TanStack Form

The same flow as the React Hook Form and Conform versions, built with TanStack Form. The Stepperize hand-off is identical — only the field API differs.

npm install @tanstack/react-form

Setup

The shared definition is the same in every implementation. Stepperize owns the flow; TanStack Form owns each step's fields and their validation. With form-driven navigation (Pattern 8), TanStack Form's validators run on submit before stepper.next(), so the form library is the validation source of truth.

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 drives the TanStack Form validator. 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)

defaultValues seeds from the saved draft (Pattern 2). A standard-schema validator wires field validation. onSubmit saves and advances (Pattern 1).

import { useForm } from "@tanstack/react-form";

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

  const form = useForm({
    defaultValues: stepper.data.get("personal") ?? { name: "", email: "" },
    validators: { onChange: personalSchema },
    onSubmit: async ({ value }) => {
      const moved = await stepper.next({ data: value });
      if (moved) stepper.setComplete();
    },
  });

  return (
    <form
      onSubmit={(e) => {
        e.preventDefault();
        form.handleSubmit();
      }}
    >
      <form.Field name="name">
        {(field) => (
          <>
            <input
              value={field.state.value}
              onBlur={field.handleBlur}
              onChange={(e) => field.handleChange(e.target.value)}
            />
            {field.state.meta.errors.length > 0 && (
              <p role="alert">{field.state.meta.errors.join(", ")}</p>
            )}
          </>
        )}
      </form.Field>

      <form.Field name="email">
        {(field) => (
          <input
            value={field.state.value}
            onBlur={field.handleBlur}
            onChange={(e) => field.handleChange(e.target.value)}
          />
        )}
      </form.Field>

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

form.handleSubmit() runs the validators; on success onSubmit fires, saving the value into Stepperize and moving. An invalid step never reaches stepper.next(). Same Pattern 8 as the others.

Want TanStack Form state, field meta, or expensive widgets to stay mounted while users move between steps? Wrap each step panel with React Activity and keep Stepperize in charge of navigation.

Review and edit

Identical to every implementation — the review step is pure Stepperize.

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={() => api.createOrder(all)}>Place order</button>
    </>
  );
}

Render the active step

function Checkout() {
  // One Provider so the step components and this renderer share one instance.
  return (
    <checkout.Provider>
      <CheckoutFlow />
    </checkout.Provider>
  );
}

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

How TanStack Form maps to the patterns

PatternTanStack Form
Seed from draftuseForm({ defaultValues: stepper.data.get(id) })
Field validationvalidators: { onChange: schema }
Save + advanceonSubmit: ({ value }) => stepper.next({ data: value })
Navigation gate (optional)beforeStepChange on the Provider/instance
Reviewstepper.data.all() (no form library)
Edit previousstepper.goTo(id) → form re-seeds from draft

TanStack Form's Standard Schema support means the same schema you put on the step (schema: personalSchema) can power both the form's field validation and an optional beforeStepChange gate — one schema, two layers.

Compare with React Hook Form and Conform.

Edit on GitHub

Last updated on

On this page