React Hook Form
Performant, flexible forms with minimal re-renders — setup, register, Controller, and core patterns.
Recipe
Quick-reference recipe card — copy-paste ready.
"use client";
import { useForm, Controller } from "react-hook-form";
interface LoginForm {
email: string;
password: string;
rememberMe: boolean;
}
function LoginForm() {
const {
register,
handleSubmit,
control,
formState: { errors, isSubmitting },
} = useForm<LoginForm>({
defaultValues: { email: "", password: "", rememberMe: false },
});
async function onSubmit(data: LoginForm) {
await fetch("/api/login", { method: "POST", body: JSON.stringify(data) });
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
{/* register — for native inputs */}
<input {...register("email", { required: "Email is required" })} />
{errors.email && <span>{errors.email.message}</span>}
<input type="password" {...register("password", { minLength: 8 })} />
{/* Controller — for controlled components */}
<Controller
name="rememberMe"
control={control}
render={({ field }) => (
<label>
<input type="checkbox" checked={field.value} onChange={field.onChange} />
Remember me
</label>
)}
/>
<button type="submit" disabled={isSubmitting}>Log in</button>
</form>
);
}When to reach for this: When you need a form with validation, error handling, and good performance — especially when the form has more than a couple of fields.
Working Example
"use client";
import { useForm, useFieldArray } from "react-hook-form";
interface Ingredient {
name: string;
amount: string;
}
interface RecipeFormData {
title: string;
description: string;
servings: number;
ingredients: Ingredient[];
}
export function RecipeEditor() {
const {
register,
handleSubmit,
control,
formState: { errors, isSubmitting, isDirty },
reset,
watch,
} = useForm<RecipeFormData>({
defaultValues: {
title: "",
description: "",
servings: 4,
ingredients: [{ name: "", amount: "" }],
},
});
const { fields, append, remove } = useFieldArray({
control,
name: "ingredients",
});
const watchTitle = watch("title");
function onSubmit(data: RecipeFormData) {
console.log("Recipe:", data);
reset(data); // clears isDirty
}
return (
<form onSubmit={handleSubmit(onSubmit)} className="max-w-lg space-y-4">
<h2 className="text-lg font-bold">
{watchTitle || "New Recipe"}
</h2>
<div>
<input
{...register("title", { required: "Title is required" })}
placeholder="Recipe title"
className="w-full rounded border p-2"
/>
{errors.title && <p className="text-sm text-red-600">{errors.title.message}</p>}
</div>
<textarea
{...register("description")}
placeholder="Description"
className="w-full rounded border p-2"
rows={3}
/>
<input
type="number"
{...register("servings", { valueAsNumber: true, min: 1 })}
className="w-32 rounded border p-2"
/>
<fieldset className="space-y-2">
<legend className="font-semibold">Ingredients</legend>
{fields.map((field, index) => (
<div key={field.id} className="flex gap-2">
<input
{...register(`ingredients.${index}.name`, { required: true })}
placeholder="Ingredient"
className="flex-1 rounded border p-2"
/>
<input
{...register(`ingredients.${index}.amount`)}
placeholder="Amount"
className="w-24 rounded border p-2"
/>
<button type="button" onClick={() => remove(index)} className="text-red-500">
Remove
</button>
</div>
))}
<button
type="button"
onClick={() => append({ name: "", amount: "" })}
className="text-sm text-blue-600"
>
+ Add ingredient
</button>
</fieldset>
<div className="flex gap-3">
<button
type="submit"
disabled={isSubmitting}
className="rounded bg-blue-600 px-4 py-2 text-white disabled:opacity-50"
>
Save
</button>
{isDirty && <span className="self-center text-sm text-amber-600">Unsaved changes</span>}
</div>
</form>
);
}What this demonstrates:
useFormwith typeddefaultValuesregisterfor native inputs with validation rulesuseFieldArrayfor dynamic listswatchfor real-time field observationisDirtytracking andresetafter save
Deep Dive
How It Works
- RHF uses uncontrolled inputs by default —
registerattachesref,onChange,onBlur, andnameto the input - Form state lives in a ref, not React state — only the parts you subscribe to cause re-renders
handleSubmitruns validation first, then calls youronSubmitonly if validformStateproperties (errors,isDirty,isValid, etc.) are lazily evaluated via Proxy — only accessed properties trigger re-rendersControllerbridges controlled components (custom selects, date pickers) into RHF
Variations
Validation modes:
const { register } = useForm({
mode: "onBlur", // validate on blur (default: "onSubmit")
reValidateMode: "onChange", // re-validate on change after first error
});Set values programmatically:
const { setValue, getValues, trigger } = useForm();
// Set a single field
setValue("email", "new@example.com", { shouldValidate: true });
// Get all values
const all = getValues();
// Trigger validation manually
await trigger("email"); // single field
await trigger(); // all fieldsForm-level default values from async data:
const { reset } = useForm<ProfileForm>({
defaultValues: async () => {
const res = await fetch("/api/profile");
return res.json();
},
});TypeScript Notes
// Strongly typed register — catches typos at compile time
register("emial"); // TS error: "emial" is not a key of LoginForm
// Typed errors
errors.email?.message; // string | undefined
// UseFormReturn type for passing form methods as props
import type { UseFormReturn } from "react-hook-form";
function FormSection({ form }: { form: UseFormReturn<LoginForm> }) {
return <input {...form.register("email")} />;
}
// FieldPath for generic field components
import type { FieldPath, FieldValues } from "react-hook-form";
function TextInput<T extends FieldValues>({
name,
control,
}: {
name: FieldPath<T>;
control: Control<T>;
}) {
return <Controller name={name} control={control} render={({ field }) => <input {...field} />} />;
}Gotchas
-
Default values must be complete — RHF uses
defaultValuesto determine the initialisDirtystate. Omitting fields leads to incorrect dirty tracking. Fix: Always provide every field indefaultValues. -
registerreturns aref— Do not overwrite therefreturned byregister. Fix: UseControllerif you need a custom ref, or merge refs with a callback ref. -
Re-renders from formState — Destructuring
formStateat the top level subscribes to all properties. Fix: Only destructure the properties you need:const { errors } = formState. -
valueAsNumberreturnsNaNfor empty inputs — If the input is empty,valueAsNumber: truegivesNaN. Fix: Combine withsetValueAsor use Zod coercion via a resolver. -
useFieldArraykeys — Always usefield.idas thekey, not the array index. Using index causes state bugs when reordering or removing items.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
| Native FormData | Simple server action forms with minimal client logic | You need field-level validation and dynamic fields |
| Formik | You are in a legacy codebase already using Formik | Starting a new project (RHF has better performance) |
React 19 useActionState | Server-first forms without client-side JS | You need instant field validation and complex UX |
| Tanstack Form | You want framework-agnostic form logic | You need the widest ecosystem of resolvers and UI libraries |
FAQs
What is the difference between register and Controller?
registerworks with native HTML inputs by attachingref,onChange,onBlur, andnameControllerwraps controlled components (custom selects, date pickers) that needvalueandonChangeprops- Use
registerfor native inputs; useControllerfor third-party or custom components
How does react-hook-form achieve minimal re-renders?
- Form state lives in a ref, not React state, so updates do not trigger re-renders by default
formStateproperties are lazily evaluated via Proxy -- only accessed properties trigger re-renders- Only destructure what you need:
const { errors } = formStateinstead of the whole object
What does isDirty track and how do you reset it?
isDirtyistruewhen any field value differs from itsdefaultValues- Call
reset(data)after a successful save to update the baseline and setisDirtytofalse - Omitting fields from
defaultValuesleads to incorrect dirty tracking
How do you load default values from an async data source?
const { reset } = useForm<ProfileForm>({
defaultValues: async () => {
const res = await fetch("/api/profile");
return res.json();
},
});- Pass an async function to
defaultValuesand RHF resolves it automatically
What does useFieldArray provide and when should you use it?
- It manages arrays of objects with
append,remove,insert,move,swap,replace, andupdate - Each item gets a stable
field.idfor use as a React key - Use it for dynamic lists like ingredients, line items, or addresses
Gotcha: Why should you never overwrite the ref returned by register?
registerreturns arefthat RHF uses to track the input element- Overwriting it disconnects RHF from the DOM element
- Fix: use
Controllerif you need a custom ref, or merge refs with a callback ref
Gotcha: Why does valueAsNumber return NaN for empty inputs?
- When the input is empty,
Number("")returnsNaN valueAsNumber: trueuses this conversion, resulting inNaNin the form data- Fix: combine with
setValueAsor use Zod'sz.coerce.number()via a resolver instead
How do you trigger validation manually for a single field in TypeScript?
const { trigger } = useForm<FormData>();
await trigger("email"); // validates just the email field
await trigger(); // validates all fieldstriggeraccepts typed field names from the form's generic parameter
How do you pass form methods to child components with proper TypeScript types?
import type { UseFormReturn } from "react-hook-form";
function FormSection({ form }: { form: UseFormReturn<LoginForm> }) {
return <input {...form.register("email")} />;
}- Use
UseFormReturn<T>as the prop type to get full type safety
What validation modes are available and how do they differ?
"onSubmit"(default): validates only when the form is submitted"onBlur": validates when a field loses focus"onChange": validates on every keystrokereValidateModecontrols re-validation behavior after the first error
How does watch differ from getValues?
watch("title")subscribes to changes and triggers re-renders when the value changesgetValues("title")reads the current value without subscribing to changes- Use
watchfor reactive UI updates; usegetValuesfor one-time reads
Related
- RHF + Zod — Zod resolver integration
- Form Patterns Basic — login, signup, contact
- Form Patterns Complex — multi-step, dynamic fields
- shadcn Form — shadcn Form component integration