Stepperize v8Explore the changes

TanStack Form

A complete shared form flow with validation, review and final submission.

TanStack Form

This complete two-step example separates editable fields from saved flow data. TanStack Form validates the profile on submit; Stepperize saves it and marks the source complete in one accepted transition. The review screen can reopen the saved draft.

npm install @stepperize/react@^8.0.0 zod @tanstack/react-form

Complete example

Provide onFinish from your application to perform the final request. It should reject on failure. This example handles that error and lets the user retry. It uses Zod 4; see the TanStack validation guide for library-specific validation details.

signup.tsx
"use client";
import { useState } from "react";
import { defineStepper } from "@stepperize/react";
import { z } from "zod";
import { useForm } from "@tanstack/react-form";

const profileSchema = z.object({
  name: z.string().min(1, "Enter your name"),
  email: z.string().email("Enter a valid email"),
});
type Profile = z.infer<typeof profileSchema>;
const signup = defineStepper([
  { id: "profile", title: "Profile", schema: profileSchema },
  { id: "review", title: "Review" },
]);
type Props = { onFinish: (profile: Profile) => Promise<void> };

export function Signup({ onFinish }: Props) {
  return <signup.Provider><Flow onFinish={onFinish} /></signup.Provider>;
}

function Flow({ onFinish }: Props) {
  const stepper = signup.useStepperContext();
  return stepper.match({
    profile: () => <ProfileForm />,
    review: () => <Review onFinish={onFinish} />,
  });
}

function ProfileForm() {
  const stepper = signup.useStepperContext();
  const form = useForm({
    defaultValues: stepper.data.get("profile") ?? { name: "", email: "" },
    validators: { onSubmit: profileSchema },
    onSubmit: async ({ value }) => {
      await stepper.next({ data: value, complete: true });
    },
  });
  return (
    <form noValidate onSubmit={(event) => {
      event.preventDefault();
      event.stopPropagation();
      void form.handleSubmit();
    }}>
      {(["name", "email"] as const).map((name) => (
        <form.Field key={name} name={name}>
          {(field) => (
            <div>
              <label>{name === "name" ? "Name" : "Email"}
                <input
                  name={field.name}
                  type={name === "email" ? "email" : "text"}
                  value={field.state.value}
                  onBlur={field.handleBlur}
                  onChange={(event) => field.handleChange(event.target.value)}
                  aria-invalid={field.state.meta.errors.length > 0}
                />
              </label>
              {field.state.meta.errors.map((error, index) => (
                <p role="alert" key={index}>{error?.message}</p>
              ))}
            </div>
          )}
        </form.Field>
      ))}
      <form.Subscribe selector={(state) => state.isSubmitting}>
        {(isSubmitting) => <button type="submit" disabled={isSubmitting || !stepper.canNext}>Review</button>}
      </form.Subscribe>
    </form>
  );
}

function Review({ onFinish }: Props) {
  const stepper = signup.useStepperContext();
  const profile = stepper.data.get("profile");
  const [pending, setPending] = useState(false);
  const [error, setError] = useState("");
  const [finished, setFinished] = useState(false);
  async function finish() {
    if (!profile || pending) return;
    setPending(true);
    setError("");
    try {
      await onFinish(profile);
      stepper.setComplete("review");
      setFinished(true);
    } catch {
      setError("Could not save. Please try again.");
    } finally {
      setPending(false);
    }
  }
  if (finished) return <p role="status">Saved successfully.</p>;
  return (
    <section>
      <h2>Review</h2>
      <p>{profile?.name} — {profile?.email}</p>
      {error && <p role="alert">{error}</p>}
      <button type="button" disabled={pending} onClick={() => stepper.prev()}>Edit</button>
      <button type="button" disabled={!profile || pending} onClick={finish}>
        {pending ? "Saving…" : "Finish"}
      </button>
    </section>
  );
}

Why this works

  • One Provider owns the flow. Every child uses useStepperContext().
  • Field errors prevent the form submit callback from navigating.
  • next({ data, complete: true }) saves the profile and completes the profile step together.
  • Returning to the profile remounts the form, seeded from data.get("profile").
  • The final screen calls your application's onFinish; it does not call next() at a boundary.

If you expose other navigation paths, protect forward transitions with beforeStepChange on the Provider as well. linear controls eligible targets; it does not validate form fields. For async guards, handle rejected results and errors in the form submit path.

For keeping unsaved component-local fields mounted, see React Activity or Content forceMount. Compare form patterns and local/shared ownership.

Edit on GitHub

Last updated on

On this page