Multi-step wizards with FormProvider
Compose React Hook Form's FormProvider, useFormContext, and trigger into a multi-step wizard that shares one form across every step.
The invoice form has been growing. It started as a customer name and an email. Then it grew a variable list of line items, the array you wired up with useFieldArray. Now the product wants a review step before anything is saved, so the user can check the whole thing over and go back to fix a typo before committing. Stacked on one screen, that’s a wall of fields nobody enjoys filling out. The product wants it staged: customer details, then the line items, then a final review, with a Back button that actually works.
This is the last of the four triggers from the start of the chapter, a multi-step form whose state spans many components, and it’s the one where everything you’ve built so far comes together. You won’t learn a new way to validate, a new way to handle arrays, or a new submit seam. You’ll learn how to compose the pieces you already have under a single form. There are only two new APIs in this whole lesson; the rest is recombination.
Here is the example you’re building, concretely. Step 1 collects the customer and email. Step 2 is the line-items array, the exact useFieldArray setup from the previous lesson, dropped in unchanged. Step 3 reviews the assembled invoice and submits it. Three steps, one invoice, submitted once at the end.
One sentence to hold onto
Section titled “One sentence to hold onto”Before any code, here is the mental model that every wizard bug traces back to:
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, exactly as it has all chapter. The navigation, which step is currently on screen, is a separate, ordinary piece of state. It is not a field. It is not validated. It is not submitted. It’s a number that says “show step 2 right now.”
Almost every wizard mistake a beginner ships comes from blurring that line: creating a fresh form per step so the values vanish when you navigate, stuffing the current step into the form’s values, or expecting whole-form validity to gate a single step. Keep the two separate and the wizard is straightforward. Let them bleed together and the bugs become hard to trace. This lesson returns to the sentence above as each piece lands.
The shape of a form that spans steps
Section titled “The shape of a form that spans steps”If you reached for the native pattern from the previous chapter here, you’d feel the friction immediately. Each step is its own component, so the parent would need a useState per field, or a hand-rolled context, just to keep the values alive as the user moves between steps. Errors would have to be plumbed down by hand. And the strongest temptation, the one that quietly corrupts data, is to POST each step as the user finishes it, so by the end you’ve made three separate writes for one invoice that the user might still abandon.
React Hook Form answers this directly. One form instance holds all the values. The steps reach into it. Nobody threads props.
To see why that’s the win, look at what you’re avoiding. In the diagram below, the left panel is the form without a shared context: the root holds the form object, and every step needs register, control, and errors passed down as props. The lines cross everywhere, and it gets worse with every step you add. The right panel is the same wizard with the form published through a context: the root wraps the steps once, and each step pulls the same form instance straight out of that context. No threading.
FormProvider publishes it
once and each step reaches it directly.
That right-hand panel, where you publish the form once and let every step reach it, is the entire justification for the API you’re about to meet.
The current step, meanwhile, is just a number in the root. It is not inside either form box in that diagram, and that’s the point: the form holds field data, the step number holds navigation, separate, exactly as the opening sentence said.
One form, shared by every step
Section titled “One form, shared by every step”The bridge between “one form at the root” and “every step reads it” is a pair of APIs that always travel together.
The first is FormProvider . You call useForm once, at the root of the wizard, and wrap your steps in the provider. That publishes the form instance to the whole subtree below it.
The second is useFormContext . Any step component calls it to grab the same form instance the root created: register, control, formState, trigger, getValues, all of it, without a single prop being passed.
There’s one wrinkle worth naming up front so you’re not confused later. You can provide the context two ways. The bare way is <FormProvider {...form}>. The way this design system already gives you is shadcn’s <Form {...form}>, and shadcn’s <Form> is a FormProvider underneath, with the design system’s styling context layered on. They’re equivalent for our purposes, so 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 exactly what it’s been all chapter: the Field family plus Controller, never the legacy <FormField>.
Here is the root. Walk it slowly, because it’s 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: the resolver and the typed <InvoiceInput, unknown, Invoice> generic are exactly what the chapter established. The thing to fix in your head is that this is 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. It’s ordinary useState, deliberately not part of the form’s values. The form doesn’t know what step you’re on, and it 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> );};This is FormProvider in shadcn clothing. Everything inside <Form {...form}> can now reach form through useFormContext, including the three step components.
'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. Notice what’s not here: no props. The steps don’t receive register, control, or errors; they read those 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, and 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.
Two functions in there, goNext and onFinalSubmit, are referenced but not yet written. That’s on purpose. goNext is the per-step “advance” logic and onFinalSubmit is the terminal submit; each gets its own section next. For now, 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 straight 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 other field you’ve built this chapter; the only new thing is where form comes from.
The single most common wizard mistake is tied directly to these two APIs: calling useForm inside a step. If a step calls useForm instead of useFormContext, it spins up a brand-new, isolated form with its own state and its own defaults. Values entered on 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. That’s the whole rule.
Validating one step at a time
Section titled “Validating one step at a time”Here’s the genuinely non-obvious part of building a wizard. When the user clicks Next on step 1, you want to validate only step 1’s fields, customer and email, and advance if they pass. You do not want to validate step 2’s line items, because the user hasn’t filled those in yet.
Two reflexes will occur to you. Both are wrong, and it’s worth seeing why before the right answer, because these are the misconceptions this section exists to clear up.
The first reflex is form.handleSubmit. But handleSubmit runs the resolver against the whole schema. Click Next on step 1 and it immediately fails on step 2’s empty line items and step 3’s missing pieces. handleSubmit is the tool for the final submit, not for advancing a step.
The second reflex is form.formState.isValid. But isValid is a whole-form boolean: it’s only true once every field across every step passes. On step 1, with step 2 untouched, it’s false, so it would never let you advance. It’s the wrong granularity, because it answers “is the entire form valid,” not “is this one step valid.”
The right tool is trigger. You hand it the names of the fields you want checked, it runs the resolver against just those, and it 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: it returns a promise, so you await it. Forget the await and you’re testing the truthiness of a promise object, which is always truthy, so the gate always passes and the validation does nothing. This is a quiet, common bug; the symptom is “Next advances even with errors on screen.” Second, notice that trigger takes a list of field names. Where does that list come from?
Keep the field list honest
Section titled “Keep the field list honest”You could hard-code it: form.trigger(['customer', 'email']). It works today. But think about the moment the invoice schema grows a field on step 1, a phone number, say. Now there are two places that know step 1’s fields: the schema, and this hand-typed list. Add the field to the schema, forget the list, and step 1 stops validating the new field. The two drift silently, and the only symptom is a validation gap nobody notices until bad data lands.
The whole chapter has had one discipline running through it: the schema is the single source of truth. The per-step field lists are no exception. Don’t re-type them; derive them from the schema. 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 keys of the picked schema’s shape, ['customer', 'email'], straight from the projection. Add phone to the schema and to the step-1 pick, and fieldsForStep(1) includes it automatically. A projection can’t drift from its source the way a re-typed list can. That’s the senior move here: the per-step rules are a projection of the one schema, never a parallel copy of it.
You’ll have noticed it says baseInvoiceSchema, not InvoiceSchema. That’s deliberate, and the next-to-last section explains why in full. The short version: .pick and .shape are methods on a Zod object. The cross-step rule you’ll add shortly wraps the object in a .refine, and a refined schema no longer exposes them. So the picks read from the unwrapped object, baseInvoiceSchema, while the resolver gets the refined InvoiceSchema built from it. Both come from one source; nothing is copied.
One detail about picking the array step: baseInvoiceSchema.pick({ lineItems: true }) picks the entire lineItems sub-schema, both every row’s rules and the array-level .min(1) you added last lesson. So trigger('lineItems') validates every line-item row and that array-level “at least one line item” rule. Step 2’s Next correctly refuses to advance an invoice with no line items, because the root error from .min(1) is part of what got picked.
To make the two validation scopes concrete, the per-step scope versus the whole-form scope, scrub through the sequence below. Watch which fields light up when, and which API drove it.
That contrast is the takeaway: trigger(fieldNames) for “Next” checks a subset, and handleSubmit for the final submit checks the whole schema. Two scopes, two APIs. If you ever catch yourself reaching for isValid or handleSubmit to gate a single step, that’s the misconception talking.
Keeping a step’s values when the user goes back
Section titled “Keeping a step’s 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 something on step 1, and step 1’s fields are empty. Their data is gone. It’s the kind of bug that makes a user abandon the form and not come back.
It comes from the collision of two things: 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 pick.
The first strategy is conditional rendering, exactly what the root does: {step === 1 && <CustomerStep />}. When you advance, CustomerStep unmounts. Whether its values survive that unmount is governed by a single useForm option, shouldUnregister . Its default is false, which means unmounted fields keep their values in the form’s state, so when the user comes back and CustomerStep remounts, the values are still there and the inputs repopulate. Set it to true and an unmounting field’s value is dropped, which is precisely the “Back, and it’s gone” bug. For a wizard you want the default, and you get the correct behavior for free unless you actively break it.
The footgun is real, though, because shouldUnregister: true looks harmless and the difference is 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 silent footgun. With conditional rendering, advancing past step 1 unmounts CustomerStep, and shouldUnregister: true drops its values. Click Back and the customer and email fields are blank: the data is gone.
const form = useForm<InvoiceInput, unknown, Invoice>({ resolver: zodResolver(InvoiceSchema), mode: 'onBlur', defaultValues: emptyInvoice,});The correct default. No shouldUnregister line at all, so it defaults to false. The unmounted step’s values stay in form state, so Back remounts the step with everything the user typed still there.
The second strategy is to render every step and hide all but the current one: every step component stays mounted, and you hide the inactive ones with CSS (the hidden attribute, or display: none). Nothing ever unmounts, so shouldUnregister is moot and values persist trivially. The cost is a heavier DOM, because every field of every step is in the page the whole time, which matters for a tall wizard but is nothing for three steps. For short wizards, the three to five steps of this lesson’s case, either strategy is fine. For long ones, conditional rendering scales better, and the default shouldUnregister: false already keeps your data safe.
The Back button itself is trivial, and you already saw it in the root: 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. There is no per-step server write anywhere in this: Back and Next are pure client-side step changes.
One consequence is worth keeping straight, because it closes the loop with the previous section. With conditional rendering and the default, a field’s errors for a step the user hasn’t visited yet don’t show up until that field is touched or the final submit runs. That’s exactly why Next uses 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 that catches 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> element’s onSubmit, which the root wired to form.handleSubmit(onFinalSubmit). This is the one and only submit in the entire wizard. handleSubmit validates the whole schema one final time, the gate that catches anything the per-step triggers couldn’t see, including cross-step rules, and only if everything passes it calls onFinalSubmit with the fully-typed, parsed Invoice.
That Invoice type is the output type from the schema’s input-output bridge, the same useForm<InvoiceInput, unknown, Invoice> shape the chapter has used throughout: you track the input shape while editing, and you receive the parsed output shape 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 called as a plain async function. The action still does what it always did: safeParse the whole payload first, authorize, mutate inside a db.transaction, revalidate, return the Result. That parse on the server is not optional, and the wizard does not replace it. Keep the trust boundary clear: the per-step trigger calls were for the user, fast and client-side, to guide them through the steps. The action’s safeParse is for the system, and it trusts nothing the client sends. The wizard changed the client state layer; the server mutation seam is identical to a single-form submit.
On success, a wizard usually redirects. The user is done with this flow, so send them to the created invoice with the App Router’s const router = useRouter(). (A single form often stays open and resets instead; a wizard more often closes by navigating away. Both are valid, and redirect is the common wizard ending.)
The failure branch has one wizard-specific move. The action returns field errors for the 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. Reuse it as-is.
But there’s a catch unique to wizards: the offending field might live on a step the user isn’t looking at. If email is already taken, that error lands in form state, but the user is sitting 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 very same 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. The principle: React Hook Form’s job is to put the error in state; taking the user to the error is the wizard’s job, because navigation is the wizard’s job.
To reinforce the seam one more time by contrast: that single createInvoice call is the only server request in the whole wizard. Every Next before it was pure client-side validation. No per-step POST, ever.
Rules that span steps, and fields that depend on other steps
Section titled “Rules that span steps, and fields that depend on other steps”Two situations come up in real wizards that look like they’d need new machinery. They don’t, because you already have the tools. This section is pure application, which is exactly the point: wizards compose what you know.
A rule that spans steps belongs on the schema, as a .refine. Take “the line-item total must be positive.” That can’t be a single field’s rule, because no one 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 you keep the plain baseInvoiceSchema object for the per-step picks (which need .shape), and the refined InvoiceSchema is what the resolver gets, so the cross-step rule fires on the final submit. One source, two derivations, exactly the chapter’s discipline, never a copy.
If you’re diffing against the last lesson, note that total is gone from the schema. That follows the previous lesson’s move: the total isn’t a stored field, it’s derived from the line items by the <InvoiceTotal> leaf. The .refine here is what enforces the “total must be positive” rule that the old total: …positive() field used to carry implicitly. And the exported types don’t change: InvoiceInput and Invoice still read off InvoiceSchema, now the refined schema, because 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, which is where the cross-step bar lit up red in the sequence diagram earlier. The wizard’s flow doesn’t reimplement it or hand-check totals on a button click. The rule exists in exactly one place, the schema, and the final gate enforces it. Cross-step rules are schema concerns, not wizard concerns, and the wizard never hand-codes them.
A field that depends on another step’s value uses useWatch, scoped to the step that needs it. Suppose step 1 recorded whether the customer is a business, and you only want to show a “PO number” field when it is. The step that shows 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 changes in the named field, so only this step re-renders when isBusiness flips, not the wizard root and not the other steps. The framing from when you first met it still holds: scope the subscription, don’t memoize the tree. The React Compiler is handling memoization; your job is to keep the subscription narrow, and useWatch in the leaf does exactly that.
Notice that neither of these needed a new tool. The cross-step rule is a .refine you already know; the conditional field is a useWatch you already know. A wizard doesn’t invent validation or derivation machinery; it composes what the form already gives you.
What the wizard costs, and when it’s the wrong shape
Section titled “What the wizard costs, and when it’s the wrong shape”Two senior calls to close on: what you’re paying for this, and the line past which it stops being the right pattern.
Progressive enhancement is fully gone here, and that’s an accepted trade. A single form had already given some of it up earlier in the chapter, but a wizard can’t function without JavaScript at all: the step navigation, the per-step trigger, and the deferred single submit each need the bundle. There’s no no-JS fallback for a multi-step flow. What makes that acceptable is who hits an in-app wizard: signed-in users on JavaScript-on surfaces, such as onboarding, invoice creation, or a configurator, where the staged UX is a real win for them. The carve-out worth knowing is the public, marketing-funnel wizard, where no-JS reach and SEO genuinely matter; there, a multi-step JS-required wizard is the wrong shape. The right shape on a public funnel is a single-page form with progressive disclosure, where sections are revealed inline behind one native submit, which keeps progressive enhancement. (And when progressive enhancement on complex forms is non-negotiable, Conform, named at the start of the chapter, is the library to reach for.) That alternative is out of scope to build here; know it exists so you reach for it on the public funnel.
The upper bound is where a wizard outgrows one client form. Past roughly eight to ten steps, or when each step carries twenty-plus fields, or, the real signal, when the user reasonably expects to leave and come back later, you’ve crossed from “wizard” into a draft-save problem. That’s a different architecture: each step persists to a draft row on the server, and the wizard hydrates from that draft when it mounts. It deliberately reintroduces the per-step server writes this lesson told you to avoid, because the requirement is now different: resumability, not just staging. It’s out of scope here, and a later chapter builds it. Naming the ceiling matters so you don’t stretch a single client-side wizard past where it holds.
One related boundary while we’re here: you can track the current step in the URL, as ?step=2, and it’s a nice navigation aid, since it’s refresh-safe, shareable, and the browser Back button works. But the URL is a navigation aid only. The source of truth for the values is still React Hook Form’s state, never the URL. (Driving list views, such as filters and sorting, from the URL is a genuinely different concern, and a later chapter covers it.)
That’s the chapter. The wizard didn’t add a sixth tool; it combined the ones you already had. The resolver from the schema lesson validates both the per-step gate and the final submit. The five primitives gave every step the same form through FormProvider. The line-items step is the field-array lesson’s work, dropped in unchanged. All of it sits under one useForm. That’s the payoff: once the form-handling vocabulary is in place, a wizard is composition, not new surface.
And the one sentence, one last time, because it’s the thing to carry out of here:
The form owns the data. The wizard owns the navigation. One schema validates both the per-step gate and the final server parse.
Check yourself
Section titled “Check yourself”Order the lifecycle of a user completing the wizard, start to finish. This drills the temporal model and the validation-scope distinction together.
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.
useForm once, creating the single form instance every step will share. trigger(fieldsForStep(1)) passes, so setStep(2) advances to the line items. trigger('lineItems') passes — every row and the min-1 rule — so setStep(3). handleSubmit validates the whole schema. onFinalSubmit calls createInvoice(values) as a plain function. applyServerErrors writes it to form state and setStep sends the user to the step that owns it. And the one distinction the whole lesson turns on:
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.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.handleSubmit always validates the whole schema, so it fails on step 2 and 3’s empty fields. isValid is also whole-form — false until every step passes, so it never lets you off step 1. trigger(fieldNames) is the only one that runs the resolver against a subset; it returns a promise, so you await it.External resources
Section titled “External resources”The provider that publishes one useForm instance to every step — the root half of the pattern.
The consumer hook each step calls to read the shared form — no prop-drilling.
Manual, scoped validation: run the resolver against just one step's fields before advancing.
Cosden Solutions builds a multi-step form end to end, including step persistence — broader UX context for the pattern.