Skip to content
Chapter 45Lesson 5

Multi-step wizards with FormProvider

Compose React Hook Form's FormProvider and per-step trigger into a multi-step wizard that shares one form across every step.

The invoice form has outgrown one screen. It started as a customer name and email, grew the variable list of line items you wired up with useFieldArray, and now needs a review step so the user can fix a typo before anything saves. Stacked on one screen, that is a wall of fields nobody enjoys, so the form gets staged into three steps, customer details, then line items, then review, with a Back button that works.

This is the last of the four triggers from the start of the chapter, a multi-step form whose state spans many components. Nothing here is new: no new validation, no new array handling, no new submit seam, just composing what you already have under a single form and two new APIs.

Step 1 collects the customer and email, step 2 is the line-items array dropped in unchanged from the previous lesson, and step 3 reviews the assembled invoice and submits. Three steps, one invoice, submitted once at the end.

The mental model: data and navigation are separate

Section titled “The mental model: data and navigation are separate”

Every wizard bug traces back to one idea:

The form owns the field data. The wizard owns the navigation. They are separate things.

The field data, the customer, the email, every line item, lives in one React Hook Form instance, as it has all chapter. The navigation, which step is on screen, is ordinary state: a number that is not a field, not validated, and not submitted.

Blur that line and the wizard breaks: a fresh form per step drops the values when you navigate, the current step stuffed into the form’s values pollutes the data, and gating one step on whole-form validity rejects valid input.

The native pattern from the previous chapter fights you here: each step is its own component, so the root would need a useState per field to keep values alive between steps, errors plumbed down by hand, and the temptation that quietly corrupts data, POSTing each step as the user finishes it, three separate writes for one invoice the user might still abandon.

React Hook Form avoids all of that with one form instance that every step reads. Compare the two panels below. On the left, with no shared context, the root holds the form object and every step needs register, control, and errors passed down as props, with more crossing lines for every step you add. On the right, the root publishes the form through a context once, and each step pulls the same form instance straight out of it.

Without a shared context (prop-drilling)
Root holds form
CustomerStep
LineItemsStep
ReviewStep
With FormProvider
Root holds form
FormProvider context
form shared instance
CustomerStep
LineItemsStep
ReviewStep
The same wizard, two ways to share one form. Without a context the root hand-delivers the form to every step; FormProvider publishes it once and each step reaches it directly.

That right-hand panel is a pair of APIs that always travel together.

The first is FormProvider . Call useForm once, at the root, and wrap your steps in the provider, which publishes the form instance to the whole subtree below.

The second is useFormContext . Any step calls it to grab the same form instance the root created, register, control, formState, trigger, getValues, all of it, with no prop passed.

One wrinkle. You could write <FormProvider {...form}>, but the design system already hands you shadcn’s <Form {...form}>, which is a FormProvider with styling context layered on. Use <Form> since the project already imports it. This is the one place shadcn’s <Form> root earns its weight; the per-field layer stays the Field family plus Controller, as it has all chapter.

Here is the root, the spine the rest of the lesson hangs off.

'use client';
export const NewInvoiceForm = () => {
const form = useForm<InvoiceInput, unknown, Invoice>({
resolver: zodResolver(InvoiceSchema),
mode: 'onBlur',
defaultValues: emptyInvoice,
});
const [step, setStep] = useState(1);
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onFinalSubmit)}>
{step === 1 && <CustomerStep />}
{step === 2 && <LineItemsStep />}
{step === 3 && <ReviewStep />}
<div className="flex justify-between">
{step > 1 && (
<Button type="button" variant="outline" onClick={() => setStep(step - 1)}>
Back
</Button>
)}
{step < 3 ? (
<Button type="button" onClick={goNext}>Next</Button>
) : (
<Button type="submit" disabled={form.formState.isSubmitting}>
Create invoice
</Button>
)}
</div>
</form>
</Form>
);
};

The single useForm call, with the resolver and the typed <InvoiceInput, unknown, Invoice> generic the chapter established. It’s called once, here at the root and nowhere else. This one instance is the form.

'use client';
export const NewInvoiceForm = () => {
const form = useForm<InvoiceInput, unknown, Invoice>({
resolver: zodResolver(InvoiceSchema),
mode: 'onBlur',
defaultValues: emptyInvoice,
});
const [step, setStep] = useState(1);
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onFinalSubmit)}>
{step === 1 && <CustomerStep />}
{step === 2 && <LineItemsStep />}
{step === 3 && <ReviewStep />}
<div className="flex justify-between">
{step > 1 && (
<Button type="button" variant="outline" onClick={() => setStep(step - 1)}>
Back
</Button>
)}
{step < 3 ? (
<Button type="button" onClick={goNext}>Next</Button>
) : (
<Button type="submit" disabled={form.formState.isSubmitting}>
Create invoice
</Button>
)}
</div>
</form>
</Form>
);
};

const [step, setStep] = useState(1) is the navigation state: ordinary useState, deliberately not part of the form’s values. The form doesn’t know what step you’re on, and doesn’t need to.

'use client';
export const NewInvoiceForm = () => {
const form = useForm<InvoiceInput, unknown, Invoice>({
resolver: zodResolver(InvoiceSchema),
mode: 'onBlur',
defaultValues: emptyInvoice,
});
const [step, setStep] = useState(1);
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onFinalSubmit)}>
{step === 1 && <CustomerStep />}
{step === 2 && <LineItemsStep />}
{step === 3 && <ReviewStep />}
<div className="flex justify-between">
{step > 1 && (
<Button type="button" variant="outline" onClick={() => setStep(step - 1)}>
Back
</Button>
)}
{step < 3 ? (
<Button type="button" onClick={goNext}>Next</Button>
) : (
<Button type="submit" disabled={form.formState.isSubmitting}>
Create invoice
</Button>
)}
</div>
</form>
</Form>
);
};

FormProvider in shadcn clothing. Everything inside <Form {...form}>, including the three step components, can reach form through useFormContext.

'use client';
export const NewInvoiceForm = () => {
const form = useForm<InvoiceInput, unknown, Invoice>({
resolver: zodResolver(InvoiceSchema),
mode: 'onBlur',
defaultValues: emptyInvoice,
});
const [step, setStep] = useState(1);
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onFinalSubmit)}>
{step === 1 && <CustomerStep />}
{step === 2 && <LineItemsStep />}
{step === 3 && <ReviewStep />}
<div className="flex justify-between">
{step > 1 && (
<Button type="button" variant="outline" onClick={() => setStep(step - 1)}>
Back
</Button>
)}
{step < 3 ? (
<Button type="button" onClick={goNext}>Next</Button>
) : (
<Button type="submit" disabled={form.formState.isSubmitting}>
Create invoice
</Button>
)}
</div>
</form>
</Form>
);
};

The step switch: one of three sibling components renders, picked by step. No props: the steps read register, control, and errors from context themselves.

'use client';
export const NewInvoiceForm = () => {
const form = useForm<InvoiceInput, unknown, Invoice>({
resolver: zodResolver(InvoiceSchema),
mode: 'onBlur',
defaultValues: emptyInvoice,
});
const [step, setStep] = useState(1);
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onFinalSubmit)}>
{step === 1 && <CustomerStep />}
{step === 2 && <LineItemsStep />}
{step === 3 && <ReviewStep />}
<div className="flex justify-between">
{step > 1 && (
<Button type="button" variant="outline" onClick={() => setStep(step - 1)}>
Back
</Button>
)}
{step < 3 ? (
<Button type="button" onClick={goNext}>Next</Button>
) : (
<Button type="submit" disabled={form.formState.isSubmitting}>
Create invoice
</Button>
)}
</div>
</form>
</Form>
);
};

One <form> element, one onSubmit wired to handleSubmit, one submit button on the last step. The whole wizard submits exactly once, from the end. goNext and onFinalSubmit come in the next two sections.

1 / 1

Hold the structure: one useForm, one step number, the provider, the sibling steps, one <form>.

Now a step component, to make the “no prop-drilling” claim concrete. CustomerStep reads the shared form from context and renders step 1’s customer field:

const CustomerStep = () => {
const form = useFormContext<InvoiceInput, unknown, Invoice>();
return (
<FieldGroup>
<Controller
control={form.control}
name="customer"
render={({ field, fieldState }) => (
<Field>
<FieldLabel htmlFor={field.name}>Customer</FieldLabel>
<Input {...field} id={field.name} />
{fieldState.error && <FieldError>{fieldState.error.message}</FieldError>}
</Field>
)}
/>
</FieldGroup>
);
};

No props came in. useFormContext handed CustomerStep the same form the root created, so form.control here is the root’s control. The Field plus Controller layout is identical to every field this chapter; the only new thing is where form comes from.

The most common wizard mistake is tied to these two APIs: calling useForm inside a step. That spins up a brand-new, isolated form with its own state and defaults: values from other steps are invisible to it, and its own values vanish the moment you navigate away. Use one useForm per wizard, at the root, and let every step share it through context.

Per-step validation is the non-obvious part of a wizard. When the user clicks Next on step 1, you want to check only step 1’s fields, customer and email, and advance if they pass. Step 2’s line items are still empty, so validating them now would be wrong.

Two tools will occur to you, and both are wrong.

The first is form.handleSubmit. It runs the resolver against the whole schema, so clicking Next on step 1 fails immediately on step 2’s empty line items and step 3’s missing pieces. handleSubmit is for the final submit, not for advancing a step.

The second is form.formState.isValid. It’s a whole-form boolean, true only once every field across every step passes, so on step 1 with step 2 untouched it’s false and never lets you advance.

The right tool is trigger. You hand it the names of the fields to check, it runs the resolver against just those, and returns a boolean. So goNext from the root is:

const goNext = async () => {
const ok = await form.trigger(fieldsForStep(step));
if (ok) setStep(step + 1);
};

Two things to lock in. First, trigger is async: forget the await and you test the truthiness of a promise object, which is always truthy, so the gate always passes and validation does nothing. The symptom is “Next advances even with errors on screen.” Second, trigger takes a list of field names. Where does that list come from?

You could hard-code it: form.trigger(['customer', 'email']). But suppose step 1 grows a phone-number field. Now two places know step 1’s fields, the schema and this hand-typed list; add the field to the schema, forget the list, and step 1 silently stops validating it, a gap nobody notices until bad data lands.

One discipline has run through the whole chapter: the schema is the single source of truth. The per-step field lists are no exception, so derive them rather than re-type them. Zod’s .pick carves a subset of fields out of an object schema, keeping each field’s rules and messages intact:

const stepSchemas = {
1: baseInvoiceSchema.pick({ customer: true, email: true }),
2: baseInvoiceSchema.pick({ lineItems: true }),
3: baseInvoiceSchema,
} as const;
const fieldsForStep = (step: number) =>
Object.keys((stepSchemas[step] ?? baseInvoiceSchema).shape) as (keyof InvoiceInput)[];

Now the field list is the schema, projected. fieldsForStep(1) reads the picked schema’s keys, ['customer', 'email'], straight from the projection; add phone to the schema and to the step-1 pick and it appears automatically. A projection can’t drift from its source the way a re-typed list can: the per-step rules are a projection of the one schema, never a parallel copy.

Note it reads baseInvoiceSchema, not InvoiceSchema. .pick and .shape are methods on a Zod object, and the cross-step rule you’ll add shortly wraps the object in a .refine that no longer exposes them. So the picks read from the unwrapped baseInvoiceSchema while the resolver gets the refined InvoiceSchema built from it, both from one source.

Picking the array step is worth a closer look. baseInvoiceSchema.pick({ lineItems: true }) picks the entire lineItems sub-schema: every row’s rules and the array-level .min(1) from last lesson. So trigger('lineItems') validates every row and the “at least one line item” rule, and step 2’s Next correctly refuses to advance an empty invoice.

To make the two scopes concrete, per-step versus whole-form, scrub through the sequence below and watch which fields light up when, and which API drove it.

Step 1 trigger(['customer', 'email'])
customer email
Step 2
lineItems[0] lineItems[1] array rule lineItems (min 1)
Step 3
review cross-step total > 0
trigger(['customer', 'email']) — Next on step 1 runs the resolver against these two fields only. Steps 2 and 3 are untouched.
Step 1
customer email
Step 2 trigger('lineItems')
lineItems[0] lineItems[1] array rule lineItems (min 1)
Step 3
review cross-step total > 0
Each step gates on its own fields. Next on step 2 validates the line items and the array's min-1 rule — nothing else.
Step 1 handleSubmit()
customer email
Step 2 handleSubmit()
lineItems[0] lineItems[1] array rule lineItems (min 1)
Step 3 handleSubmit()
review cross-step total > 0
handleSubmit on the final step runs the resolver against the whole schema — every field, all at once. This is the final gate.
Step 1 handleSubmit()
customer email
Step 2 handleSubmit()
lineItems[0] lineItems[1] array rule lineItems (min 1)
Step 3 handleSubmit()
review cross-step total > 0
The whole-schema gate is also where cross-step rules fire — total > 0, which no single field could check alone, fails here even though every field passes.

trigger(fieldNames) for “Next” checks a subset; handleSubmit for the final submit checks the whole schema. Reaching for isValid or handleSubmit to gate a single step is the misconception talking.

Keeping field values when the user goes back

Section titled “Keeping field values when the user goes back”

Here is a bug you must never ship. The user fills out step 1, clicks Next, fills out step 2, then clicks Back to fix step 1, and the step 1 fields are empty. Their data is gone.

The cause is the interaction between how you render the steps and what React Hook Form does with a field whose input has unmounted. There are two rendering strategies, and the fix depends on which you use.

The first is conditional rendering, what the root does: {step === 1 && <CustomerStep />}. Advancing unmounts CustomerStep, and whether its values survive is governed by one useForm option, shouldUnregister . It defaults to false: unmounted fields keep their values, so when CustomerStep remounts the inputs repopulate. Set it to true and the value is dropped on unmount, which is exactly the “Back, and it’s gone” bug.

That makes shouldUnregister: true a footgun, invisible until someone clicks Back. Both tabs below render the steps identically; the only difference is that one option.

const form = useForm<InvoiceInput, unknown, Invoice>({
resolver: zodResolver(InvoiceSchema),
shouldUnregister: true,
mode: 'onBlur',
defaultValues: emptyInvoice,
});

The footgun. Advancing past step 1 unmounts CustomerStep, and shouldUnregister: true drops its values. Click Back and the customer and email fields are blank.

The second strategy is to render every step and hide all but the current one with the hidden attribute or display: none. Nothing unmounts, so shouldUnregister is moot and values persist trivially. The cost is a heavier DOM, every field on the page at once, which is nothing for three steps but adds up for a tall wizard. Either strategy is fine for the three-to-five-step wizards here; for long ones, conditional rendering scales better, and the default shouldUnregister: false already keeps the data safe.

The Back button you saw in the root is trivial: onClick={() => setStep(step - 1)}, no validation, no reset, no save. React Hook Form kept the values, so going back just changes which step is on screen. Back and Next are pure client-side step changes; nothing writes to the server.

With conditional rendering and the default, a field’s errors for an unvisited step don’t appear until that field is touched or the final submit runs. That’s why Next calls trigger on the visible step’s fields to surface this step’s errors on demand, and why the final handleSubmit is the whole-form backstop for anything still unseen.

Submitting once, and routing server errors to the right step

Section titled “Submitting once, and routing server errors to the right step”

The last step’s button is type="submit", so it fires the <form>’s onSubmit, which the root wired to form.handleSubmit(onFinalSubmit). This is the wizard’s one and only submit. handleSubmit validates the whole schema one final time, the gate that catches what the per-step triggers couldn’t see, including cross-step rules, and only if everything passes calls onFinalSubmit with the parsed, fully-typed Invoice.

That Invoice is the schema’s output type, the same useForm<InvoiceInput, unknown, Invoice> shape used throughout: you track the input shape while editing and receive the parsed output on submit. From there, the Server Action seam is unchanged from earlier in the chapter:

const onFinalSubmit = async (values: Invoice) => {
const result = await createInvoice(values);
if (result.ok) {
router.push(`/invoices/${result.data.id}`);
return;
}
applyServerErrors(form, result);
setStep(stepOfFirstError(result.error.fieldErrors));
};

createInvoice is a plain async call, and the action does what it always did: safeParse the whole payload, authorize, mutate inside a db.transaction, revalidate, return the Result. The trust boundary holds: the per-step triggers were for the user, fast and client-side, to guide them through the steps; the action’s safeParse is for the system, and trusts nothing the client sends. The wizard changed the client state layer, not the server seam.

On success, a wizard usually redirects rather than reset in place: the user is done, so send them to the created invoice with the App Router’s const router = useRouter().

The failure branch has one wizard-specific move. The action returns field errors for rules the client can’t know, such as an email already taken or a plan limit hit: { ok: false, error: { fieldErrors: { email: ['Already taken'] } } }. You feed those into the form with applyServerErrors, the same helper from earlier in the chapter: it loops the fieldErrors and setErrors each one, so they land in formState.errors and render through the same <FieldError> rows as every other error.

But a wizard adds a catch: the offending field might live on a step the user isn’t looking at. A taken email lands in form state, but the user is on step 3, the review, and never sees it. So after applyServerErrors, you take the user to the error:

const stepOfFirstError = (fieldErrors: Record<string, string[]>) => {
const errored = Object.keys(fieldErrors);
if (errored.some((field) => field in stepSchemas[1].shape)) return 1;
if (errored.some((field) => field in stepSchemas[2].shape)) return 2;
return 3;
};

This reuses the step-to-fields map the .pick projections already define, so there’s no new bookkeeping. It finds the lowest step that owns an errored field, and setStep sends the user there, where the <FieldError> is waiting. React Hook Form puts the error in state; taking the user to it is the wizard’s job, because navigation is the wizard’s job.

Two situations come up in real wizards that look like they need new machinery but don’t, because you already have the tools.

A rule that spans steps belongs on the schema, as a .refine. Take “the line-item total must be positive.” No single field knows the total; it’s a property of the whole lineItems array read together. So it’s a top-level refinement on the schema, with path pointing at the field the error should attach to:

const baseInvoiceSchema = z.object({
customer: z.string().min(1),
email: z.email(),
lineItems: z.array(lineItemSchema).min(1),
});
export const InvoiceSchema = baseInvoiceSchema.refine(
(invoice) => sumLineItems(invoice.lineItems) > 0,
{ path: ['lineItems'], message: 'The invoice total must be greater than zero.' },
);

This is the base-and-refined split the earlier section promised to explain. .refine wraps the object in a new schema that runs the whole-object check but no longer exposes .pick or .shape, since those are object-only methods. So the plain baseInvoiceSchema serves the per-step picks that need .shape, and the refined InvoiceSchema goes to the resolver, where the cross-step rule fires on the final submit. One source, two derivations, never a copy.

If you’re diffing against the last lesson, total is gone from the schema. The total isn’t stored, it’s derived from the line items by the <InvoiceTotal> leaf, and this .refine enforces the “total must be positive” rule that the old total: …positive() field carried implicitly. The exported types don’t change: InvoiceInput and Invoice still read off InvoiceSchema, since z.input/z.output work the same on a refined schema as on a plain object.

The resolver runs this refinement on the final handleSubmit, the whole-schema gate where the cross-step bar lit up red in the sequence diagram. The wizard never reimplements it or hand-checks totals on a button click; cross-step rules are schema concerns, not wizard concerns.

A field that depends on another step’s value uses useWatch, scoped to the step that needs it. Say step 1 recorded whether the customer is a business, and you only want a “PO number” field when it is. The step with the conditional field watches the dependency and renders accordingly:

const CustomerStep = () => {
const form = useFormContext<InvoiceInput, unknown, Invoice>();
const isBusiness = useWatch({ control: form.control, name: 'isBusiness' });
return (
<FieldGroup>
{/* customer, email, isBusiness fields */}
{isBusiness && (
<Controller
control={form.control}
name="poNumber"
render={({ field, fieldState }) => (
<Field>
<FieldLabel htmlFor={field.name}>PO number</FieldLabel>
<Input {...field} id={field.name} />
{fieldState.error && <FieldError>{fieldState.error.message}</FieldError>}
</Field>
)}
/>
)}
</FieldGroup>
);
};

useWatch is the same re-render lever from earlier in the chapter: it subscribes this component to the named field, so only this step re-renders when isBusiness flips, not the root and not the other steps. The rule still holds: scope the subscription, don’t memoize the tree. The React Compiler handles memoization; your job is to keep the subscription narrow, and useWatch in the leaf does exactly that.

What a wizard costs, and when it’s the wrong shape

Section titled “What a wizard costs, and when it’s the wrong shape”

Two senior calls to close on: what you pay for a wizard, and the line past which it stops being the right pattern.

A wizard gives up progressive enhancement entirely, and that’s the accepted trade. Step navigation, the per-step trigger, and the deferred single submit all need the bundle, so there is no no-JS fallback. That’s fine for who hits an in-app wizard: signed-in users on JavaScript-on surfaces like onboarding, invoice creation, or a configurator, where the staged UX is a real win. The exception is a public, marketing-funnel wizard, where no-JS reach and SEO matter. There, reach instead for a single-page form with progressive disclosure, sections revealed inline behind one native submit; or when progressive enhancement is non-negotiable, Conform.

Past one client form, a wizard becomes a draft-save problem. The signal is roughly eight to ten steps, twenty-plus fields per step, or, the real one, a user who expects to leave and come back later. That’s a different architecture: each step persists to a draft row and the wizard hydrates from it on mount, reintroducing the per-step server writes this lesson avoided, because the requirement is now resumability, not just staging. A later chapter builds it.

You can track the current step in the URL as ?step=2, a nice navigation aid: refresh-safe, shareable, and Back works. But it’s a navigation aid only; the source of truth for the values stays in React Hook Form’s state, never the URL.

Order the wizard’s lifecycle, start to finish.

Order the steps of a user completing the three-step invoice wizard, from first mount to handling a server error. Drag the items into the correct order, then press Check.

The root mounts and calls useForm once, creating the single form instance every step will share.
The user fills in step 1 (customer, email) and clicks Next.
trigger(fieldsForStep(1)) passes, so setStep(2) advances to the line items.
The user adds line items in step 2 and clicks Next.
trigger('lineItems') passes — every row and the min-1 rule — so setStep(3).
On the review step the user clicks Create invoice, and handleSubmit validates the whole schema.
onFinalSubmit calls createInvoice(values) as a plain function.
The action returns a field error, so applyServerErrors writes it to form state and setStep sends the user to the step that owns it.

Now the call that checks one step without submitting the form:

The user is on step 1 and clicks Next. You want to check only step 1’s fields before advancing — not step 2 or 3. Which call does that?

form.handleSubmit(goNext) — it runs validation, then advances.
Read form.formState.isValid and advance when it’s true.
await form.trigger(fieldsForStep(1)) and advance if it returns true.
form.handleSubmit with the other steps’ fields temporarily removed from the schema.