Multi-step forms
Collect one typed payload across several steps and validate it on the server.
A multi-step form (a wizard or funnel) collects one payload across several screens. There are two common shapes:
- One form, several steps. Every step renders under one
useForm. Use this when the steps share a page and you want one source of truth for the values, as in the schema resolver example. - One page per step. Each step is its own route with its own
useForm, and a store holds the draft between pages. Use this when each step needs a URL, as in the built-in validation example.
Both examples submit to the same endpoint contract. POST /api/signup returns HTTP 201 with { ok: true, accountId }, or HTTP 422 with { ok: false, fieldErrors }, where fieldErrors maps a field name to a message. Whichever step the user is on, a server error sends them back to the step that owns the field.
One form with a schema resolver
One Zod schema describes the whole form. The client validates with it through zodResolver, and the server parses the request with the same schema, so the two cannot drift apart. little state machine keeps the draft in sessionStorage, so a reload doesn't lose the user's answers. This example needs zod, @hookform/resolvers, and little-state-machine:
npm install zod @hookform/resolvers little-state-machine
schema.ts: shared by the client and the server. Each step's field list comes from its own schema, so renaming a field cannot leave a step validating a field that no longer exists.
import { z } from "zod"const accountSchema = z.object({email: z.string().trim().email("Enter a valid email address."),password: z.string().min(8, "Use at least 8 characters."),})const profileSchema = z.object({fullName: z.string().trim().min(1, "Enter your name."),company: z.string().trim(),})const planSchema = z.object({plan: z.enum(["free", "pro", "team"]),})// One schema for the whole form. The client and the server both import it.export const signupSchema = z.object({...accountSchema.shape,...profileSchema.shape,...planSchema.shape,})export type SignupValues = z.infer<typeof signupSchema>export type SignupField = keyof SignupValues// Each step lists its fields, derived from its schema, so a renamed field// is a type error instead of a step that silently skips validation.export const steps: { title: string; fields: readonly SignupField[] }[] = [{ title: "Account", fields: accountSchema.keyof().options },{ title: "Profile", fields: profileSchema.keyof().options },{ title: "Plan", fields: planSchema.keyof().options },]export function isSignupField(name: string): name is SignupField {return signupSchema.keyof().safeParse(name).success}// Index of the first step that contains one of these fields, or -1.export function firstStepWith(names: string[]) {return steps.findIndex((step) =>step.fields.some((field) => names.includes(field)))}export type SignupResponse =| { ok: true; accountId: string }| { ok: false; fieldErrors: Partial<Record<SignupField, string>> }
app/api/signup/route.ts: a route handler that uses the web Request and Response APIs. It returns the first error for each field, and reports rules that only the server can check, such as a taken email address, in the same shape.
import {isSignupField,signupSchema,type SignupField,type SignupResponse,} from "./schema"// Replace with your application's data layer.import { createAccount, emailExists } from "./db"function reply(body: SignupResponse, status: number) {return Response.json(body, { status })}export async function POST(request: Request) {const result = signupSchema.safeParse(await request.json().catch(() => null))if (!result.success) {const fieldErrors: Partial<Record<SignupField, string>> = {}for (const issue of result.error.issues) {const name = String(issue.path[0])if (isSignupField(name)) fieldErrors[name] ??= issue.message}return reply({ ok: false, fieldErrors }, 422)}// Rules only the server can check use the same response shape.if (await emailExists(result.data.email)) {return reply({ok: false,fieldErrors: { email: "An account with this email already exists." },},422)}const account = await createAccount(result.data) // hash the password herereturn reply({ ok: true, accountId: account.id }, 201)}
draft.ts: the little state machine store for the draft. It is typed as SignupValues without the password.
import { createStore, type GlobalState } from "little-state-machine"import type { SignupValues } from "./schema"// Every field except the password, which must never be stored in the browser.export type SignupDraft = Omit<SignupValues, "password">declare module "little-state-machine" {interface GlobalState {signupDraft: SignupDraft}}const emptyDraft: SignupDraft = {email: "",fullName: "",company: "",plan: "free",}// little state machine saves the store to sessionStorage after each action// and restores it on reload.createStore({ signupDraft: emptyDraft })export function saveDraft(state: GlobalState,values: SignupValues): GlobalState {// List the stored fields explicitly, so the password can't slip in.const { email, fullName, company, plan } = valuesreturn { ...state, signupDraft: { email, fullName, company, plan } }}export function clearDraft(state: GlobalState): GlobalState {return { ...state, signupDraft: emptyDraft }}
SignupWizard.tsx: one useForm for every step. Only the current step is rendered.
"use client"import { useEffect, useState } from "react"import { FormProvider, useForm, useFormContext } from "react-hook-form"import { zodResolver } from "@hookform/resolvers/zod"import { useStateMachine } from "little-state-machine"import { clearDraft, saveDraft } from "./draft"import {firstStepWith,isSignupField,signupSchema,steps,type SignupField,type SignupResponse,type SignupValues,} from "./schema"export default function SignupWizard() {const [step, setStep] = useState(0)const [accountId, setAccountId] = useState<string>()const { actions, state } = useStateMachine({actions: { saveDraft, clearDraft },})const methods = useForm<SignupValues>({resolver: zodResolver(signupSchema),// Restore the saved draft. The password always starts empty.defaultValues: { ...state.signupDraft, password: "" },mode: "onTouched",})const {handleSubmit,trigger,setError,subscribe,formState: { errors, isSubmitting },} = methodsconst isLastStep = step === steps.length - 1// Save the draft on every change. skipRender stores it without// re-rendering this component.useEffect(() =>subscribe({formState: { values: true },callback: ({ values }) =>actions.saveDraft(values, { skipRender: true }),}),[subscribe, actions])const goToFirstError = (names: string[]) => {const index = firstStepWith(names)if (index !== -1) setStep(index)}const next = async () => {// Validate only the current step's fields before moving on.if (await trigger(steps[step].fields, { shouldFocus: true })) {setStep(step + 1)}}const onSubmit = async (values: SignupValues) => {try {const response = await fetch("/api/signup", {method: "POST",headers: { "Content-Type": "application/json" },body: JSON.stringify(values),})if (!response.ok && response.status !== 422) throw new Error()const result: SignupResponse = await response.json()if (result.ok) {actions.clearDraft()setAccountId(result.accountId)return}const names = Object.keys(result.fieldErrors).filter(isSignupField)for (const name of names) {setError(name, { type: "server", message: result.fieldErrors[name] })}goToFirstError(names)} catch {setError("root.server", {type: "server",message: "We couldn't create your account. Please try again.",})}}if (accountId) return <p role="status">Your account is ready.</p>return (<FormProvider {...methods}><formnoValidateonSubmit={isLastStep? handleSubmit(onSubmit, (errors) =>goToFirstError(Object.keys(errors))): (event) => {event.preventDefault()next()}}><h2>Step {step + 1} of {steps.length}: {steps[step].title}</h2>{step === 0 && (<><TextField name="email" label="Email" type="email" /><TextField name="password" label="Password" type="password" /></>)}{step === 1 && (<><TextField name="fullName" label="Full name" /><TextField name="company" label="Company (optional)" /></>)}{step === 2 && <PlanStep />}{errors.root?.server && (<p role="alert">{errors.root.server.message}</p>)}{step > 0 && (<button type="button" onClick={() => setStep(step - 1)}>Back</button>)}<button type="submit" disabled={isSubmitting}>{isLastStep ? "Create account" : "Next"}</button></form></FormProvider>)}function TextField({name,label,type = "text",}: {name: Exclude<SignupField, "plan">label: stringtype?: string}) {const {register,formState: { errors },} = useFormContext<SignupValues>()const error = errors[name]?.messagereturn (<div><label htmlFor={name}>{label}</label><inputid={name}type={type}aria-invalid={!!error}aria-describedby={error ? `${name}-error` : undefined}{...register(name)}/>{error && (<p id={`${name}-error`} role="alert">{error}</p>)}</div>)}function PlanStep() {const { register } = useFormContext<SignupValues>()return (<fieldset><legend>Plan</legend>{(["free", "pro", "team"] as const).map((plan) => (<label key={plan}><input type="radio" value={plan} {...register("plan")} /> {plan}</label>))}</fieldset>)}
How it fits together:
- Step validation.
triggerruns the resolver against the whole form, but it reports errors only for the fields you name. Later steps stay quiet until the user reaches them. - Hidden steps keep their values. Fields from earlier steps unmount when the step changes, but their values stay in the form because
shouldUnregisterdefaults tofalse. Don't set it totruehere. - Final submission.
handleSubmitvalidates every field. If a field on an earlier step is invalid, for example a password that was not restored from the draft, the second argument sends the user back to that step. - Server errors.
setErrormarks each field the server rejected, then the form shows the first step that contains one.isSignupFieldkeeps unknown keys in the response out of the form state. - Drafts.
subscribepasses each change to thesaveDraftaction, and{ skipRender: true }stores it without re-rendering the form.saveDraftlists the stored fields explicitly, so the password never reachessessionStorage. On reload, the store's draft becomes the form'sdefaultValues.
One page per step with built-in validation
Each route has its own useForm with register rules, and little state machine holds the draft between pages, as in the previous example. It saves the store to sessionStorage by default, so the draft survives a reload. React context or your existing state library also works. This example needs little-state-machine and react-router:
npm install little-state-machine react-router
store.ts: the shared SignupValues type, the response contract, the store, and the page that owns each field.
import { createStore, type GlobalState } from "little-state-machine"export type SignupValues = {email: stringfullName: stringcompany: stringplan: "free" | "pro" | "team"}export type SignupField = keyof SignupValues// The contract for POST /api/signup. The server must validate every field again.export type SignupResponse =| { ok: true; accountId: string }| { ok: false; fieldErrors: Partial<Record<SignupField, string>> }declare module "little-state-machine" {interface GlobalState {signup: SignupValues}}const emptySignup: SignupValues = {email: "",fullName: "",company: "",plan: "free",}// little state machine saves the store to sessionStorage after each action,// so the draft survives a reload. Don't put secrets such as passwords in it.createStore({ signup: emptySignup })export function updateSignup(state: GlobalState,payload: Partial<SignupValues>): GlobalState {return { ...state, signup: { ...state.signup, ...payload } }}export function clearSignup(state: GlobalState): GlobalState {return { ...state, signup: emptySignup }}// The page that owns each field. Because the type is a Record, adding a field// without a page is a type error.export const fieldRoute: Record<SignupField, string> = {email: "/signup",fullName: "/signup/profile",company: "/signup/profile",plan: "/signup/plan",}
App.tsx: one route per step.
import { BrowserRouter, Route, Routes } from "react-router"import "./store" // creates the store once, at the app rootimport { AccountStep, PlanStep, ProfileStep } from "./steps"export default function App() {return (<BrowserRouter><Routes><Route path="/signup" element={<AccountStep />} /><Route path="/signup/profile" element={<ProfileStep />} /><Route path="/signup/plan" element={<PlanStep />} /><Route path="/signup/done" element={<p>Your account is ready.</p>} /></Routes></BrowserRouter>)}
steps.tsx: each step validates its own fields, saves them to the store, and moves on. The last step submits the whole draft.
import { useForm, type FieldErrors } from "react-hook-form"import { useLocation, useNavigate } from "react-router"import { useStateMachine } from "little-state-machine"import {clearSignup,fieldRoute,updateSignup,type SignupField,type SignupResponse,type SignupValues,} from "./store"// Errors the plan page sent back after a failed submission, if any.function useServerErrors() {const location = useLocation()return location.state?.errors as FieldErrors<SignupValues> | undefined}export function AccountStep() {const { actions, state } = useStateMachine({ actions: { updateSignup } })const navigate = useNavigate()const {register,handleSubmit,formState: { errors },} = useForm<Pick<SignupValues, "email">>({defaultValues: { email: state.signup.email },errors: useServerErrors(),})return (<formnoValidateonSubmit={handleSubmit((values) => {actions.updateSignup(values)navigate("/signup/profile")})}><label htmlFor="email">Email</label><inputid="email"type="email"aria-invalid={!!errors.email}{...register("email", {required: "Enter your email address.",pattern: {value: /^\S+@\S+\.\S+$/,message: "Enter a valid email address.",},})}/>{errors.email && <p role="alert">{errors.email.message}</p>}<button type="submit">Next</button></form>)}export function ProfileStep() {const { actions, state } = useStateMachine({ actions: { updateSignup } })const navigate = useNavigate()const {register,handleSubmit,getValues,formState: { errors },} = useForm<Pick<SignupValues, "fullName" | "company">>({defaultValues: {fullName: state.signup.fullName,company: state.signup.company,},errors: useServerErrors(),})return (<formnoValidateonSubmit={handleSubmit((values) => {actions.updateSignup(values)navigate("/signup/plan")})}><label htmlFor="fullName">Full name</label><inputid="fullName"aria-invalid={!!errors.fullName}{...register("fullName", {validate: (value) => value.trim() !== "" || "Enter your name.",})}/>{errors.fullName && <p role="alert">{errors.fullName.message}</p>}<label htmlFor="company">Company (optional)</label><input id="company" {...register("company")} /><buttontype="button"onClick={() => {// Keep unfinished answers when going back, without validating them.actions.updateSignup(getValues())navigate("/signup")}}>Back</button><button type="submit">Next</button></form>)}export function PlanStep() {const { actions, state } = useStateMachine({actions: { updateSignup, clearSignup },})const navigate = useNavigate()const {register,handleSubmit,setError,formState: { errors, isSubmitting },} = useForm<Pick<SignupValues, "plan">>({defaultValues: { plan: state.signup.plan },})const onSubmit = async ({ plan }: Pick<SignupValues, "plan">) => {actions.updateSignup({ plan })// The store updates asynchronously, so build the payload from both sources.const values: SignupValues = { ...state.signup, plan }try {const response = await fetch("/api/signup", {method: "POST",headers: { "Content-Type": "application/json" },body: JSON.stringify(values),})if (!response.ok && response.status !== 422) throw new Error()const result: SignupResponse = await response.json()if (result.ok) {actions.clearSignup()navigate("/signup/done")return}// Send the user to the page that owns the first invalid field.const fields = Object.keys(fieldRoute) as SignupField[]const first = fields.find((field) => result.fieldErrors[field])if (!first) throw new Error()const errors: FieldErrors<SignupValues> = {}for (const field of fields) {const message = result.fieldErrors[field]if (message) errors[field] = { type: "server", message }}navigate(fieldRoute[first], { state: { errors } })} catch {setError("root.server", {type: "server",message: "We couldn't create your account. Please try again.",})}}return (<form noValidate onSubmit={handleSubmit(onSubmit)}><fieldset><legend>Plan</legend>{(["free", "pro", "team"] as const).map((plan) => (<label key={plan}><input type="radio" value={plan} {...register("plan")} /> {plan}</label>))}</fieldset>{errors.root?.server && <p role="alert">{errors.root.server.message}</p>}<button type="button" onClick={() => navigate("/signup/profile")}>Back</button><button type="submit" disabled={isSubmitting}>Create account</button></form>)}
How it fits together:
- Typed slices. Each step types its form as
Pick<SignupValues, ...>, so a step can only register its own fields, and the store merges each slice into the fullSignupValues. - Going back. The Back button saves the step with
getValues()before navigating, so unfinished answers are kept without being validated. - Server errors. Built-in rules exist only in the browser, so the server must validate the payload itself. On HTTP 422, the plan step navigates to the page that owns the first rejected field and passes the errors in router state. That page shows them through the
useFormerrorsoption. - Finishing. After a successful response,
clearSignupresets the stored draft so the next visit starts empty.
Whichever version you use, the server is the final check. It must validate and authorize the complete payload, because the client steps can be skipped or edited.