React
Forms in React: React Hook Form Patterns You'll Actually Reuse
Production patterns for React Hook Form — register vs Controller, Zod resolvers, useFormContext, field arrays, server errors, and the re-render traps that make forms feel slow.

React Hook Form keeps forms fast by minimising re-renders and leaning on native validation where possible. These are the patterns that survive contact with a real codebase — the ones we reach for on client builds rather than the ones that look neat in a demo.
Register and uncontrolled by default
Register inputs with register("fieldName") and let the DOM hold the value. That avoids a state update on every keystroke. Use uncontrolled fields for simple text inputs, checkboxes, and selects when you don't need to drive the value from outside.
import { useForm } from "react-hook-form";
function SignupForm() {
const { register, handleSubmit, formState: { errors } } = useForm();
return (
<form onSubmit={handleSubmit((data) => console.log(data))}>
<input
{...register("email", {
required: "Email is required",
pattern: {
value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
message: "Invalid email",
},
})}
/>
{errors.email && <span role="alert">{errors.email.message}</span>}
<button type="submit">Submit</button>
</form>
);
}Keep validation in the second argument to register for small rules; move to a schema once the rules are shared or interdependent.
Schema validation with Zod
Inline rules stop scaling the moment two fields depend on each other, or the same shape is validated on the server. A resolver moves the rules into one object you can export.
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
export const signupSchema = z
.object({
email: z.string().email("Enter a valid email"),
password: z.string().min(12, "Use at least 12 characters"),
confirm: z.string(),
})
.refine((data) => data.password === data.confirm, {
message: "Passwords do not match",
path: ["confirm"],
});
type SignupValues = z.infer<typeof signupSchema>;
const { register, handleSubmit } = useForm<SignupValues>({
resolver: zodResolver(signupSchema),
});Two things this buys you. z.infer gives you the form's type for free, so register("emial") becomes a compile error rather than a silent no-op. And the same schema can be imported by your API route or server action, so client and server can never disagree about what valid means.
The path on a .refine() matters — without it the error attaches to the form root and never appears next to the field.
When to use Controller
Use control and Controller when the input isn't a native form element: a design-system input that doesn't forward refs, a rich text editor, a third-party date picker.
<Controller
name="startDate"
control={control}
rules={{ required: true }}
render={({ field }) => (
<DatePicker
value={field.value}
onChange={field.onChange}
onBlur={field.onBlur}
ref={field.ref}
/>
)}
/>For native inputs, stick with register. Wrapping a plain <input> in a Controller re-renders that field on every keystroke, which is the cost you adopted the library to avoid.
useFormContext for deep forms
Prop-drilling register through three layers of layout components is where forms usually start to rot. FormProvider puts the form methods on context so any descendant can reach them.
import { FormProvider, useForm, useFormContext } from "react-hook-form";
function CheckoutForm() {
const methods = useForm<CheckoutValues>({ resolver: zodResolver(schema) });
return (
<FormProvider {...methods}>
<form onSubmit={methods.handleSubmit(onSubmit)}>
<AddressFieldset />
<PaymentFieldset />
</form>
</FormProvider>
);
}
function AddressFieldset() {
const { register, formState: { errors } } = useFormContext<CheckoutValues>();
return <input {...register("address.line1")} aria-invalid={!!errors.address?.line1} />;
}Pass the values type to useFormContext<CheckoutValues>() or you lose autocomplete on field names inside the child — the most common reason teams give up on this pattern and go back to prop-drilling.
Worth knowing: any component calling useFormContext subscribes to form state, so a large form with many context consumers can re-render broadly. If that bites, read specific fields with useWatch instead of pulling the whole context.
Repeating fields with useFieldArray
For line items, team invites, or anything the user can add rows to, useFieldArray manages the list and keeps keys stable.
const { fields, append, remove } = useFieldArray({ control, name: "items" });
return (
<>
{fields.map((field, index) => (
<div key={field.id}>
<input {...register(`items.${index}.sku` as const)} />
<button type="button" onClick={() => remove(index)}>Remove</button>
</div>
))}
<button type="button" onClick={() => append({ sku: "" })}>Add item</button>
</>
);Key on field.id, never on index. React Hook Form generates that id precisely so removing a middle row doesn't make React reuse the wrong DOM node and leave a stale value on screen.
Server errors belong on fields
Client validation catches shape. The server catches truth — the email is already taken, the coupon expired. Put those back on the field with setError rather than dumping a banner at the top of the form.
async function onSubmit(values: SignupValues) {
const res = await fetch("/api/signup", { method: "POST", body: JSON.stringify(values) });
if (res.ok) return;
const { fieldErrors } = await res.json();
for (const [name, message] of Object.entries(fieldErrors)) {
setError(name as keyof SignupValues, { type: "server", message: String(message) });
}
}Use formState.isSubmitting to disable the submit button rather than tracking your own loading flag — it already accounts for async resolvers.
Error display and accessibility
Show errors next to the field and associate them properly, or screen reader users get a form that fails silently.
<input
{...register("email")}
aria-invalid={!!errors.email}
aria-describedby={errors.email ? "email-error" : undefined}
/>
{errors.email && (
<span id="email-error" role="alert">{errors.email.message}</span>
)}The mode option controls when validation runs. onSubmit is the default and the least noisy. onTouched validates after first blur, which is usually the best balance — onChange shouts at people while they are still typing their email address.
Watch the re-render traps
Three habits account for most "React Hook Form feels slow" reports:
watch()in the component body re-renders the whole form on every change. UseuseWatch({ control, name: "field" })in a small child so only that child re-renders.- Destructuring
formStateearly subscribes you to more than you need.formStateis a Proxy; it only tracks what you actually read, so read it where you use it. - Controller around native inputs, as above.
A reusable field wrapper
Wrap label, input, and error once and every form in the codebase inherits the same structure and accessibility.
function Field({ name, label, children }: FieldProps) {
const { formState: { errors } } = useFormContext();
const error = errors[name];
return (
<div>
<label htmlFor={name}>{label}</label>
{children}
{error && <span id={`${name}-error`} role="alert">{String(error.message)}</span>}
</div>
);
}Use the same wrapper for select, checkbox, and textarea; only the inner element changes. This is the single highest-leverage piece of form code in most projects — it's where consistency and accessibility either happen everywhere or nowhere.
Summary
Use register and uncontrolled inputs by default, and Controller only for custom or third-party components. Move validation to a Zod schema as soon as rules are shared, and export that schema so the server validates identically. Reach for useFormContext when forms get deep and useFieldArray when rows repeat. Put server errors back on the fields that caused them, keep watch() out of the component body, and wrap label-input-error once so accessibility is structural rather than remembered.
For how these forms fit a larger codebase, see React component architecture that scales and accessible components in React.
Related reading
- ReactError Boundaries and Resilient UI: Patterns for ProductionHow to use error boundaries so one failing component doesn’t take down the whole app—and how to recover.
- ReactReact Component Architecture That Scales in Real ProjectsHow to structure React components so they stay maintainable as your app grows—without over-engineering. From first components to large codebases.
- ReactBuilding Accessible Components in React Without Slowing DownPractical patterns for keyboard support, focus management, and ARIA in React—without over-engineering.
More in React