React Hook Form
Performant, flexible forms with minimal re-renders - setup, register, Controller, and core patterns.
Search across all documentation pages
Performant, flexible forms with minimal re-renders - setup, register, Controller, and core patterns.
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
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.
"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:
useForm with typed defaultValuesregister for native inputs with validation rulesuseFieldArray for dynamic listswatch for real-time field observationisDirty tracking and reset after saveregister attaches ref, onChange, onBlur, and name to the inputhandleSubmit runs validation first, then calls your onSubmit only if validformState properties (errors, isDirty, isValid, etc.) are lazily evaluated via Proxy - only accessed properties trigger re-rendersController bridges controlled components (custom selects, date pickers) into RHFValidation 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();
},
});// 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} />} />;
}Default values must be complete - RHF uses defaultValues to determine the initial isDirty state. Omitting fields leads to incorrect dirty tracking. Fix: Always provide every field in defaultValues.
register returns a ref - Do not overwrite the ref returned by register. Fix: Use Controller if you need a custom ref, or merge refs with a callback ref.
Re-renders from formState - Destructuring formState at the top level subscribes to all properties. Fix: Only destructure the properties you need: const { errors } = formState.
valueAsNumber returns NaN for empty inputs - If the input is empty, valueAsNumber: true gives NaN. Fix: Combine with setValueAs or use Zod coercion via a resolver.
useFieldArray keys - Always use field.id as the key, not the array index. Using index causes state bugs when reordering or removing items.
| 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 |
register works with native HTML inputs by attaching ref, onChange, onBlur, and nameController wraps controlled components (custom selects, date pickers) that need value and onChange propsregister for native inputs; use Controller for third-party or custom componentsformState properties are lazily evaluated via Proxy -- only accessed properties trigger re-rendersconst { errors } = formState instead of the whole objectisDirty is true when any field value differs from its defaultValuesreset(data) after a successful save to update the baseline and set isDirty to falsedefaultValues leads to incorrect dirty trackingconst { reset } = useForm<ProfileForm>({
defaultValues: async () => {
const res = await fetch("/api/profile");
return res.json();
},
});defaultValues and RHF resolves it automaticallyappend, remove, insert, move, swap, replace, and updatefield.id for use as a React keyregister returns a ref that RHF uses to track the input elementController if you need a custom ref, or merge refs with a callback refNumber("") returns NaNvalueAsNumber: true uses this conversion, resulting in NaN in the form datasetValueAs or use Zod's z.coerce.number() via a resolver insteadconst { trigger } = useForm<FormData>();
await trigger("email"); // validates just the email field
await trigger(); // validates all fieldstrigger accepts typed field names from the form's generic parameterimport type { UseFormReturn } from "react-hook-form";
function FormSection({ form }: { form: UseFormReturn<LoginForm> }) {
return <input {...form.register("email")} />;
}UseFormReturn<T> as the prop type to get full type safety"onSubmit" (default): validates only when the form is submitted"onBlur": validates when a field loses focus"onChange": validates on every keystrokereValidateMode controls re-validation behavior after the first errorwatch("title") subscribes to changes and triggers re-renders when the value changesgetValues("title") reads the current value without subscribing to changeswatch for reactive UI updates; use getValues for one-time readsReviewed by Chris St. John·Last updated Jul 16, 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥