Stepperize v8Explore the changes

React Hook Form

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

React Hook Form

This complete two-step example separates editable fields from saved flow data. React Hook 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 react-hook-form @hookform/resolvers

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 resolver documentation 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 "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";

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 { register, handleSubmit, formState } = useForm<Profile>({
    resolver: zodResolver(profileSchema),
    defaultValues: stepper.data.get("profile") ?? { name: "", email: "" },
  });
  return (
    <form noValidate onSubmit={handleSubmit(async (data) => {
      await stepper.next({ data, complete: true });
    })}>
      <label>Name <input {...register("name")} aria-invalid={!!formState.errors.name} /></label>
      {formState.errors.name && <p role="alert">{formState.errors.name.message}</p>}
      <label>Email <input type="email" {...register("email")} aria-invalid={!!formState.errors.email} /></label>
      {formState.errors.email && <p role="alert">{formState.errors.email.message}</p>}
      <button type="submit" disabled={formState.isSubmitting || !stepper.canNext}>Review</button>
    </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