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-formComplete 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.
"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 callnext()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.
Last updated on