This guide shows how to build a production-ready multi-step form with React Hook Form, Zod, and Next.js, from an empty app to a working Server Action without storing form values in useState. It focuses on the practical problems developers encounter in complex forms, including component libraries, conditional fields, validation, server errors, and performance.
TL;DR
- React Hook Form keeps input values in refs instead of React state. Typing 19 characters into a 20-field form commits 19 times with
useStateand 0 times with react-hook-form. - A validation rule written inline in
registerlives in JSX, and a server action cannot import JSX. One Zod schema runs on both sides and produces the form’s TypeScript types throughz.infer. registeronly drives native inputs, so a component library needsControllerwired to the callback it actually exposes, which for Radix isonValueChangeand notonChange.- For a multi-step form, keep one
useFormat the wizard level and calltrigger(fieldsOfThisStep). The resolver validates the whole schema but only sets errors for the fields you name. - Rendering Next and Submit in the same conditional slot makes the last step submit itself. React morphs the DOM node mid-click; distinct
keyvalues stop it. - Keeping the form out of the initial bundle saved 88 KB gzipped on a production page, and the reCAPTCHA it drags in weighs nearly four times the form itself.
What is React Hook Form used for?
React Hook Form is a library for managing form state and validation in React. This React Hook Form guide shows how to build validated forms, multi-step wizards, dynamic field arrays, and Next.js server actions with shared Zod schemas.
It solves two problems that grow together:
- Boilerplate: a controlled form needs state, a change handler and a validation branch for every field. Repeat that twenty times and the file is hard to read.
- Speed: each of those fields is bound to React state, so typing re-renders the tree. By the time a form is big enough to be worth the effort, it’s big enough to feel slow.
Why do React forms get slower as they grow?
A controlled form re-renders every time you type a character, but React Hook Form avoids those re-renders.
You can see the difference in performance. Two forms with twenty fields each, same markup and React version, are tested side-by-side. After typing nineteen characters into one field:
| React commits while typing 19 characters | |
|---|---|
Controlled, useState |
19, one per keystroke |
| react-hook-form | 0 |
The count comes from React’s DevTools commit hook, across three runs with the order reversed: 19, 17 and 11 characters, one commit per keystroke against none every time. The counter was checked against an action that has to commit, and a failed validation raised exactly one, so the zero is real. React 19.2.8, react-hook-form 7.86.
The difference is where the value lives:
- A controlled input’s value is React state: every keystroke calls a setter and re-renders the tree below it.
- An uncontrolled input owns its value, the way a plain HTML input does. React Hook Form keeps a ref and reads it on submit, or through a subscription you ask for.
That trade is the whole library: no single source of truth per character, no re-render while someone types.
React Hook Form vs Formik vs Redux Form
Which form library should you choose?
-
React Hook Form is usually the best default choice for new React forms. It uses uncontrolled inputs and minimizes re-renders while providing strong TypeScript and validation support.
-
Keep Formik if you already use it and the forms are small. The migration cost is often higher than the performance benefit.
-
Keep Redux Form only if you inherited it and have not budgeted a migration yet. It carries no deprecation flag on npm, but the pace of development has slowed significantly compared with modern alternatives such as React Hook Form.
React Hook Form vs Formik vs Redux Form: comparison of form state management, rendering behavior, validation support, and package size.
| React Hook Form | Formik | Redux Form | |
|---|---|---|---|
| Where values live | Refs, uncontrolled | React state | The Redux store |
| Re-renders while typing | None | Every keystroke | Every keystroke, plus a dispatch |
| API | Hooks | Components and hooks | Higher-order components |
| Schema validation | Resolver: Zod, Yup, Valibot | Yup, built in | Manual or Yup |
| Runtime dependencies | None | Eight, including lodash | Eight, plus redux and react-redux as peers |
| Last release | 7.88.0, September 2026 | 2.4.9, November 2025 | 8.3.10, March 2023 |
What does a multi-step form in React need?
A real agency lead form, in three steps: contacts, project details, then timeline and reference links. It validates per step, shares one Zod schema with the server, and submits through a Next.js server action.
Built and verified against:
- Next.js 16.3.2 on the App Router, React 19.2.8, TypeScript
- react-hook-form 7.86, zod 4.4.3,
@hookform/resolvers5.9.1 - radix-ui 1.6.7 and react-day-picker 10, under shadcn/ui on Tailwind
shadcn/ui is not a dependency you install: its CLI copies component source into your repo, and those components are Radix primitives with Tailwind classes on them. Both names appear below and they mean different layers: Radix decides how a component behaves, shadcn decides how you compose it.
Step one of the brief form. Every section below adds one capability to this same form.
React Hook Form: How useForm, register, and handleSubmit work
Three pieces. useForm creates the form, register connects an input to it, and handleSubmit validates and gives you the values. React Hook Form’s own API documentation covers every option; this section is the short path through it.
One package to start:
npm install react-hook-form
import { useForm } from "react-hook-form";
const {
register,
handleSubmit,
formState: { errors },
} = useForm();
<Input id="name" placeholder="Jane Doe" {...register("name")} />;
{
errors.name && (
<p className="text-sm text-destructive">{errors.name.message}</p>
);
}
register("name")returns the props a native input needs:name,onChange,onBlurand aref. Spreading them puts the field under React Hook Form’s control. Note what is absent: novalueprop, nouseState.handleSubmit(onSubmit)calls your handler only after validation passes, and hands it data already typed.formState.errorsis the one part that does re-render, and only in the component reading it.useForm()is untyped here on purpose. The types arrive in the next section, derived from the schema rather than written by hand.
React Hook Form with Zod: One Schema for Validation and Types
React Hook Form works especially well with Zod because a single schema can define validation rules, generate TypeScript types, and run on both the client and the server.
React Hook Form can validate inline, and register("email", { required: true, pattern: /…/ }) works. But those rules sit in JSX. The server can’t use them, and TypeScript learns nothing from them.
To use React Hook Form with Zod, install the schema library and the resolver that connects Zod to React Hook Form.
npm install zod @hookform/resolvers
import * as z from "zod";
export const contactsSchema = z.object({
name: z.string().min(2, "Name must be at least 2 characters"),
email: z.email("Enter a valid email"),
company: z.string().optional(),
});
export type ContactsValues = z.infer<typeof contactsSchema>;
Wire it in with the resolver, and useForm becomes fully typed from the schema:
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
const form = useForm<ContactsValues>({ resolver: zodResolver(contactsSchema) });
z.infer builds the TypeScript type from the schema, so the rules and the types can’t drift apart. Field names are checked against it too: register("emial") stops compiling.
Each step of the brief gets its own schema this way: contactsSchema, projectDetailsSchema, timelineRefsSchema. The multi-step section composes them into one.
One warning if you’re following an older React Hook Form with Zod tutorial: most examples were written for Zod 3, and several APIs changed in Zod 4. Check the code against Zod’s migration guide.
Why doesn’t register work with a Select or a date picker?
Because register speaks native DOM events, and component libraries don’t. A Radix Select emits onValueChange with a value. The props register hands you expect onChange with an event, so they land on nothing. This applies to the shadcn Select too, because it is that Radix Select underneath.
The demo’s Select lists the headless CMSs we work with. If you’re still choosing a headless CMS for React, that comparison is a separate read.
Controller is the bridge. It subscribes to the field and gives you a field object to wire up yourself:
import { Controller } from "react-hook-form";
<Controller
control={control}
name="projectType"
render={({ field }) => (
<RadioGroup value={field.value ?? ""} onValueChange={field.onChange}>
{/* … */}
</RadioGroup>
)}
/>;
Two details in that snippet matter:
- Wire
onValueChange={field.onChange}rather than spreading{...field}, because spreading passes a DOMonChangethat Radix ignores, and the field silently never updates. - Pass
value={field.value ?? ""}, because the""keeps the component controlled from the first render, whereundefinedmakes Radix switch modes mid-life and warn.
If you use shadcn/ui with React Hook Form, note that the current shadcn React Hook Form guide builds forms from the Field parts with Controller directly. Most tutorials still show the older Form/FormField wrapper, and neither approach is deprecated, but the Field components are what shadcn documents today, and they are what this article uses.
React Hook Form Conditional Fields: watch vs useWatch
A conditional field in React Hook Form appears only when another field holds a specific value. For conditional fields, subscribe to the controlling field with useWatch. Bare watch() signs the component up for the entire form, so it re-renders on every change, which is exactly the performance cost React Hook Form is designed to avoid.
import { useWatch } from "react-hook-form";
// re-renders only when projectType changes
const projectType = useWatch({ control, name: "projectType" });
| Subscribes to | Re-renders on | |
|---|---|---|
watch() |
the entire form | any change to any field |
watch("projectType") |
one field, but in the parent’s render | that field |
useWatch({ control, name }) |
one field, in its own subscription | that field only |
Commerce platform appears only for an eCommerce project. One useWatch subscription decides it.
Why not use a discriminated union for conditional fields?
A discriminated union looks like the obvious answer for “required only when the type is e-commerce”, but it is the wrong tool here. A union turns the inferred form type into a union of variants, so useForm loses track of the field names. defaultValues can’t cover every variant, and register("platform") stops typechecking. You lose the whole point of inferring types from the schema.
.refine() keeps one flat type, and it reads like the rule itself:
export const projectDetailsSchema = z
.object({
projectType: z.enum(
["website", "cms-migration", "e-commerce", "performance-audit"],
{
error: "Select a project type",
},
),
cms: z.enum(["sanity", "storyblok", "contentful" /* … */], {
error: "Select a CMS",
}),
platform: z.string().optional(),
description: z.string().max(500, "Keep it under 500 characters").optional(),
})
.refine(
(data) => data.projectType !== "e-commerce" || !!data.platform?.trim(),
{
error: "Commerce platform is required for eCommerce projects",
path: ["platform"],
},
);
path: ["platform"] is what puts the error on the field instead of at the form root.
The downside is that Zod skips refinements while the base fields are still failing. Submit an e-commerce brief with no CMS and you get the CMS error first, then the platform error on the next submit.
How do you build an add-and-remove list of fields?
React Hook Form’s useFieldArray owns the array and gives you fields, append and remove.
The third and last schema, which the wizard composes with the other two:
export const timelineRefsSchema = z.object({
timeline: z.enum(["urgent", "1-2-weeks", "within-a-month" /* … */], {
error: "Select a timeline",
}),
deadline: z.date().optional(),
references: z
.array(
z.object({
url: z.url("Enter a valid URL"),
note: z.string().optional(),
}),
)
.max(5, "Up to 5 references"),
});
import { useFieldArray } from "react-hook-form";
const { fields, append, remove } = useFieldArray({
control,
name: "references",
});
{
fields.map((field, index) => <div key={field.id}>{/* … */}</div>);
}
<Button onClick={() => append({ url: "", note: "" })}>Add reference</Button>;
Rows added and removed at runtime by useFieldArray. Each keeps its own values because the key is the row id, not its index.
Use key={field.id} and never key={index}, because React Hook Form generates a stable id per row. With index keys, removing row two makes React reuse row three’s DOM node in row two’s place, and the input keeps the old row’s text.
Three more things to know:
append()is typed from the schema, so it won’t let you add a row of the wrong shape. Pass empty strings rather than nothing, or the new inputs start uncontrolled.- A row’s own error sits at
errors.references?.[index]?.url. The.max(5)message does not: it lands aterrors.references?.root. - Set the limit in two places.
disabledon the Add button so the user sees it, and.max(5)in the schema because the client can be bypassed.
React Hook Form Multi-Step Form Validation
One useForm at the wizard level, FormProvider for the steps, and trigger() on the fields of the current step. Only the wizard is a form; a step is just a group of fields.
First the schemas from the earlier sections become one:
export const briefSchema = contactsSchema
.and(projectDetailsSchema)
.and(timelineRefsSchema);
export type BriefValues = z.infer<typeof briefSchema>;
.and() rather than .extend(), because projectDetailsSchema carries a .refine() and plain .extend() throws on a refined schema in Zod 4. Zod’s .safeExtend() is the other way round it, and either route keeps the rule and its path.
import { useForm, type FieldPath } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
const steps: { title: string; fields: FieldPath<BriefValues>[] }[] = [
{ title: "Contacts", fields: ["name", "email", "company"] },
{
title: "Project Details",
fields: ["projectType", "cms", "platform", "description"],
},
{
title: "Timeline & References",
fields: ["timeline", "deadline", "references"],
},
];
// The wizard owns two pieces of state: which step is showing, and the form itself.
const [step, setStep] = useState(0);
const form = useForm<BriefValues>({ resolver: zodResolver(briefSchema) });
const next = async () => {
const fields = steps[step].fields;
const valid = await form.trigger(fields, { shouldFocus: true });
if (valid) {
setStep((s) => s + 1);
}
};
The important detail: trigger(names) runs the resolver over the whole form. It only sets errors for the names you asked for. That keeps step three quiet while the user is on step one. On a very large form it’s also the cost, because the whole schema runs on every step change.
This is where the state survives. Moving off step one unmounts its inputs, but their values stay in the form, because shouldUnregister defaults to false. Back and Next only change which step renders, so the user’s earlier answers are still there when they come back, and the final submit re-validates the whole schema.
Why does clicking Next submit the whole form?
If Next and Submit render in the same conditional slot, the last step submits the whole form on the click that takes you there. Every step the user hasn’t filled in then shows errors.
const isLastStep = step === steps.length - 1;
// The form lives in the card body; the buttons live in the footer, outside it.
// `form="brief-form"` is what lets the submit button reach it.
{
isLastStep ? (
<Button key="submit" type="submit" form="brief-form">
Submit brief
</Button>
) : (
<Button key="next" type="button" onClick={next}>
Next
</Button>
);
}
The bug is not in React Hook Form. It happens because React and the browser disagree about when a click is finished.
- React reuses the DOM node. Next and Submit occupy the same position in the tree, so React edits the existing button instead of creating a new one.
- The
nexthandler isasync, so React flushes the queued re-render at the microtask checkpoint before the browser finishes processing the click. - By then, the button’s
typehas changed frombuttontosubmit, so the browser runs the default action for a submit button and submits the form.
Different key values fix it. React then replaces the node instead of editing it, so the button the browser finishes the click on is the one it started on. Verified with a Playwright event trace on RHF 7.86: trigger() is correctly scoped and isn’t the culprit.
How do you validate the same schema on the server?
Import the same schema in the server action and parse there too. The schema React Hook Form runs on the client is a UX feature. The one on the server is the real validation.
"use server";
import * as z from "zod";
import { briefSchema, type BriefValues } from "./brief-schema";
export type SubmitBriefResult =
| { ok: true }
| { ok: false; fieldErrors: Partial<Record<keyof BriefValues, string[]>> };
export async function submitBrief(values: unknown): Promise<SubmitBriefResult> {
const parsed = briefSchema.safeParse(values);
if (!parsed.success) {
return { ok: false, fieldErrors: z.flattenError(parsed.error).fieldErrors };
}
return { ok: true };
}
z.flattenError is the Zod 4 API for turning a parse failure into per-field message arrays.
A real Date survives the boundary: React puts it on the wire as "$D2026-09-04T21:00:00.000Z" and it arrives as a Date, so z.date() parses it server-side with no z.coerce. Watch the timezone, though, because a local September 5 travels as September 4 at 21:00Z.
How do you get a server error onto the right step?
Mapping errors into the form is not enough. If the failing field is on step one and the user is on step three, they see nothing.
import { flushSync } from "react-dom";
const errored = Object.keys(result.fieldErrors) as (keyof BriefValues)[];
for (const field of errored) {
form.setError(field, {
type: "server",
message: result.fieldErrors[field]?.[0],
});
}
const firstErroredStep = steps.findIndex((s) =>
errored.some((field) => s.fields.includes(field)),
);
if (firstErroredStep === -1) return;
// flushSync commits the step change first, so the input exists when we focus it.
flushSync(() => setStep(firstErroredStep));
const target = errored.find((f) => steps[firstErroredStep].fields.includes(f));
if (target) form.setFocus(target);
The server rejected the email, so the form jumped back to step one and put the cursor in the field that faile.
setError(field, …, { shouldFocus: true }) looks like it should cover this, but the docs are explicit that it only works while the input’s reference is registered, and after a step jump it is not. setError and setStep run in the same tick, so the new step hasn’t rendered when focus is requested and the call does nothing. flushSync commits the step change first, which is why the input exists by the time setFocus asks for it.
How do you check a value against the database while the user is still typing?
Write the check as an async refinement on the schema.
Once you pass a resolver, the validation rules you write in register stop running: the resolver owns validation, and an async validate function on the field is ignored.
With Zod, the check goes in the schema and zodResolver does the rest. It calls parseAsync, so a refinement that returns a promise works as written. isDomainBlocked here is your own function, calling whatever endpoint answers the question:
const contactsSchema = z.object({
name: z.string().min(2, "Name must be at least 2 characters"),
email: z
.email("Enter a valid email")
.refine(
async (email) => !(await isDomainBlocked(email)),
"We already have an open brief for this domain",
),
company: z.string().optional(),
});
Set mode: 'onBlur' when you do this, because the default validates as the user types, which means one request per keystroke against your own endpoint:
const form = useForm<BriefValues>({
resolver: zodResolver(briefSchema),
mode: "onBlur",
});
Two things worth knowing before you ship this:
- The client check only saves the user a failed submit, because the rule that actually protects anything runs on the server, where nobody can skip it.
- An async refinement makes the whole schema async, so
safeParsestops being enough and anything parsing that schema outside the form, the server included, needssafeParseAsync.
How do you run every form on a site from one React Hook Form setup?
You stop writing forms and start rendering them. On a production site we build and maintain, one React Hook Form instance and one renderer cover contact, careers, franchise enquiries and party bookings, all from a schema that non-developers edit in the CMS.
It did not start that way: the previous build of the same site spread six forms across six page files, 4,888 lines between them, with no shared form component anywhere.
| Before | Now | |
|---|---|---|
| Forms | Six, one per page file | One renderer, driven by the CMS |
| Validation rules | Inline in JSX, next to each input | One function, keyed on the field’s type |
| Error messages | Optional in practice | Impossible to omit |
That last row is the one that cost a real user something. The old contact form registered a name field like this:
{...register("fullName", {
required: "Name is required",
pattern: /^[a-zA-Z ]+$/,
})}
The pattern carries no message, and the markup below it only rendered an error when errors.fullName.type === "required". Type a digit into your name and the border turned red, the type was "pattern", the paragraph never rendered, and the form told you nothing at all. The rule that replaced it is dull by comparison: every entry returns { value, message }, so there is nowhere to put a rule without one.
The snippets below come from that codebase. Copy the shape; the helpers and types belong to that project.
const { register, control, handleSubmit, formState, setFocus } =
useForm<FormSubmissionData>({ defaultValues, mode: "onBlur" });
<form onSubmit={handleSubmit(onSubmit, onValidationError)} noValidate>
{fields.map((field) => (
<FormFieldRenderer key={field.name} field={field} control={control} />
))}
</form>;
fields comes from the CMS. An editor adds a phone field to the careers form, and it appears, validated, with no deploy.
Two rules made that work. A field’s type decides its rules, and all of them live in one function, so the regex guarding one field can’t go missing on the next. Every rule returns { value, message }, so there is nowhere to put a bare pattern: /^[a-z]+$/, which turns the field red and tells the user nothing.
One more thing worth wiring once. handleSubmit takes a second argument almost nobody uses, and it runs when validation fails:
const onValidationError = (errors: FieldErrors<FormSubmissionData>) => {
const [first] = Object.keys(errors) as FieldPath<FormSubmissionData>[];
if (first) setFocus(first);
};
On a long form the first error is often off-screen. Without this, the user presses Submit, sees nothing happen, and decides the button is broken.
Does the form need to be in the first load?
Usually not, and that is the cheapest win on a site like this one. Measured on the site above, on a page whose form is the last block on it, against an otherwise comparable page with no form at all:
| Initial JS for the page, gzipped | |
|---|---|
| Form imported directly | 677 KB |
| Form behind a lazy import | 589 KB |
That is 88 KB off the initial load, 13%, and it is the whole form: React Hook Form, the field renderer and the validation rules.
The lazy build eventually fetches about 3 KB more in total, since chunk splitting and the skeleton cost something. The win is in the timing: 88 KB stop blocking hydration.
Wrapping the form in next/dynamic and stopping there saves nothing: on the demo built for this article it cost 1.2 KB, because Next preloads the chunk for a component that renders immediately. The switch is ssr: false, which a Server Component can’t use, and its loading fallback is declared at module level, so it can’t see props either. A skeleton meant to match this form has no way to know what its fields are.
"use client";
import { lazy, Suspense, useEffect, useState } from "react";
import { FormSkeleton } from "./FormSkeleton";
const FormBlockClient = lazy(() =>
import("./FormBlockClient").then((m) => ({ default: m.FormBlockClient })),
);
export const FormBlockDynamic = ({ form }: { form: FormType }) => {
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
if (!mounted) return <FormSkeleton form={form} />;
return (
<Suspense fallback={<FormSkeleton form={form} />}>
<FormBlockClient form={form} />
</Suspense>
);
};
Writing the Suspense yourself is what lets the fallback take form, and the mounted guard is what replaces ssr: false, because Next prerenders a plain React.lazy subtree anyway. The cost is the skeleton: no <form> in the server HTML, eighteen skeleton nodes hydrating into twelve inputs. A bad trade when the form is the page, free when it sits below the fold.
The same page also pulls 355 KB gzipped of Google reCAPTCHA, 3.9 times the entire cost of the form. Of the roughly 446 KB of form-related JavaScript a form page ships, four fifths is the spam protection. It loads only where a form exists, so the same fix applies: keep it behind the lazy boundary and it never reaches a page without a form.
What breaks once it’s in production?
Errors nobody hears, a disabled field that silently stops submitting, a form that never fills from the API, a button that never enables, and a network error you caused yourself. None of these is in the quickstart.
- A disabled input submits as
undefined, which is React Hook Form’s documented behaviour rather than a bug, so a pre-filled field that you disable quietly stops arriving at the server. The docs point you atreadOnly, or at disabling the parent<fieldset>, when you want the value kept. defaultValuesis read once on the first render, so a form populated from an API stays empty when the data lands a moment later. Thevaluesoption is the one that keeps syncing as the data changes.formStateis a Proxy that subscribes only to the properties you actually read, sodisabled={!formState.isValid}inside a conditional never re-renders when validity changes. Destructuring first,const { isDirty, isValid } = formState, is what registers the subscription.- React Hook Form hands you
errorsand stops there, so a screen reader says nothing until you link each message to its input witharia-describedbyandaria-invalidand give the messagerole="alert". Don’t reach foraria-live="polite"here. A polite region is announced only at the next graceful opportunity, at the end of the current sentence or when the user stops typing, so after a failed submit, when focus jumps to the first bad field, the message can be queued behind that change and never heard.role="alert"carries assertive semantics and is announced immediately. - Cancelling your own request lands in
catch: when a new submit aborts the previous one, the old promise rejects with aDOMExceptionnamedAbortError, and unless you check for it you show a network error for something you did on purpose.
Planning a migration or a rebuild?
FocusReactive builds fast web platforms: headless CMS setups, platform migrations, and frontend rebuilds that a real content team has to live with every day.
We start with an honest read on the project before anyone commits budget: what the scope really is, how long it will take, and whether your tools fit the business goal. We also say where the hidden technical debt and the migration risks sit.
Oleg Proskurin