React Hook Form
React Hook Form — useForm, register, Controller, validation, nested fields, arrays (useFieldArray), DevTools, and performance optimization patterns
React Hook Form embraces uncontrolled components and native HTML validation to minimize re-renders. Instead of managing every keystroke in state, it registers inputs via refs, only triggering re-renders when validation state changes. This architecture delivers exceptional performance even in forms with hundreds of fields. The library provides a small API surface — `useForm`, `register`, `handleSubmit`, `Controller` — that covers the vast majority of form scenarios without boilerplate. ## Key Points - **Always provide `defaultValues`** — this enables `reset()` to work correctly, improves type inference, and avoids uncontrolled-to-controlled warnings. - **Use `mode: "onBlur"`** for most forms — it gives users time to type before showing errors, reducing noise. - **Prefer `zodResolver`** over inline `rules` for any form with cross-field validation or complex logic. It keeps validation co-located and testable. - **Use `useFieldArray` for dynamic lists** — never manually manage array indices in state alongside RHF. - **Leverage `valueAsNumber` and `valueAsDate`** on register options to avoid manual type coercion. - **Isolate heavy components with `useWatch`** — subscribe to specific fields in child components instead of watching at the form root, preventing unnecessary re-renders. - **Call `reset()` after successful submission** when the form stays mounted (e.g., modals that reopen). - **Set `shouldUnregister: false`** (the default) for wizard/multi-step forms so that navigating between steps preserves values. - **Using array index as `key`** — always use the `field.id` provided by `useFieldArray` as the React key. Index-based keys cause inputs to lose their values when items are reordered or removed. - **Calling `setValue` in a render loop** — this triggers re-renders. Move it into `useEffect` or event handlers. - **Skipping `noValidate` on the form element** — without it, native browser validation tooltips appear alongside your custom error messages, confusing users. - **Overusing `trigger()`** — manually triggering validation on every change defeats the performance benefit of the library. Trust the configured `mode` and `reValidateMode`. ## Quick Example ```typescript npm install react-hook-form @hookform/resolvers zod ```
skilldb get forms-validation-skills/React Hook FormFull skill: 252 linesReact Hook Form
Core Philosophy
React Hook Form embraces uncontrolled components and native HTML validation to minimize re-renders. Instead of managing every keystroke in state, it registers inputs via refs, only triggering re-renders when validation state changes. This architecture delivers exceptional performance even in forms with hundreds of fields. The library provides a small API surface — useForm, register, handleSubmit, Controller — that covers the vast majority of form scenarios without boilerplate.
Setup
Install the core library and optional schema resolver:
npm install react-hook-form @hookform/resolvers zod
Basic form scaffold:
import { useForm } from "react-hook-form";
interface SignupForm {
email: string;
password: string;
confirmPassword: string;
}
export function SignupPage() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<SignupForm>({
defaultValues: { email: "", password: "", confirmPassword: "" },
mode: "onBlur", // validate on blur
reValidateMode: "onChange", // re-validate on change after first error
});
const onSubmit = async (data: SignupForm) => {
await fetch("/api/signup", {
method: "POST",
body: JSON.stringify(data),
});
};
return (
<form onSubmit={handleSubmit(onSubmit)} noValidate>
<input
type="email"
{...register("email", {
required: "Email is required",
pattern: { value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/, message: "Invalid email" },
})}
/>
{errors.email && <span role="alert">{errors.email.message}</span>}
<input
type="password"
{...register("password", {
required: "Password is required",
minLength: { value: 8, message: "At least 8 characters" },
})}
/>
{errors.password && <span role="alert">{errors.password.message}</span>}
<button type="submit" disabled={isSubmitting}>Sign Up</button>
</form>
);
}
Key Techniques
Schema-Based Validation with Zod
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
const schema = z
.object({
email: z.string().email("Invalid email"),
password: z.string().min(8, "At least 8 characters"),
confirmPassword: z.string(),
})
.refine((d) => d.password === d.confirmPassword, {
message: "Passwords must match",
path: ["confirmPassword"],
});
type FormData = z.infer<typeof schema>;
export function SignupForm() {
const { register, handleSubmit, formState: { errors } } = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: { email: "", password: "", confirmPassword: "" },
});
return (
<form onSubmit={handleSubmit((data) => console.log(data))}>
<input {...register("email")} />
{errors.email && <p>{errors.email.message}</p>}
{/* remaining fields */}
</form>
);
}
Controller for Controlled Components
Use Controller when integrating with UI libraries (MUI, Radix, React Select) that require value/onChange props:
import { Controller, useForm } from "react-hook-form";
import Select from "react-select";
interface ProjectForm {
name: string;
priority: { value: string; label: string };
}
export function ProjectSettings() {
const { control, register, handleSubmit } = useForm<ProjectForm>();
return (
<form onSubmit={handleSubmit(console.log)}>
<input {...register("name", { required: true })} />
<Controller
name="priority"
control={control}
rules={{ required: "Select a priority" }}
render={({ field, fieldState: { error } }) => (
<>
<Select
{...field}
options={[
{ value: "low", label: "Low" },
{ value: "medium", label: "Medium" },
{ value: "high", label: "High" },
]}
/>
{error && <span>{error.message}</span>}
</>
)}
/>
</form>
);
}
Dynamic Arrays with useFieldArray
import { useForm, useFieldArray } from "react-hook-form";
interface InvoiceForm {
lineItems: { description: string; amount: number }[];
}
export function InvoiceEditor() {
const { control, register, handleSubmit } = useForm<InvoiceForm>({
defaultValues: { lineItems: [{ description: "", amount: 0 }] },
});
const { fields, append, remove, move } = useFieldArray({
control,
name: "lineItems",
});
return (
<form onSubmit={handleSubmit(console.log)}>
{fields.map((field, index) => (
<div key={field.id}>
<input {...register(`lineItems.${index}.description` as const, { required: true })} />
<input
type="number"
{...register(`lineItems.${index}.amount` as const, { valueAsNumber: true })}
/>
<button type="button" onClick={() => remove(index)}>Remove</button>
</div>
))}
<button type="button" onClick={() => append({ description: "", amount: 0 })}>
Add Item
</button>
<button type="submit">Submit</button>
</form>
);
}
Nested Fields and Watch
const { register, watch, setValue } = useForm<{
address: { street: string; city: string; state: string; zip: string };
}>();
// Watch a specific nested field
const zip = watch("address.zip");
// Auto-fill city/state from ZIP lookup
useEffect(() => {
if (zip?.length === 5) {
lookupZip(zip).then(({ city, state }) => {
setValue("address.city", city);
setValue("address.state", state);
});
}
}, [zip, setValue]);
DevTools
import { DevTool } from "@hookform/devtools";
function MyForm() {
const { control } = useForm();
return (
<>
<form>{/* fields */}</form>
{process.env.NODE_ENV === "development" && <DevTool control={control} />}
</>
);
}
Best Practices
- Always provide
defaultValues— this enablesreset()to work correctly, improves type inference, and avoids uncontrolled-to-controlled warnings. - Use
mode: "onBlur"for most forms — it gives users time to type before showing errors, reducing noise. - Prefer
zodResolverover inlinerulesfor any form with cross-field validation or complex logic. It keeps validation co-located and testable. - Use
useFieldArrayfor dynamic lists — never manually manage array indices in state alongside RHF. - Leverage
valueAsNumberandvalueAsDateon register options to avoid manual type coercion. - Isolate heavy components with
useWatch— subscribe to specific fields in child components instead of watching at the form root, preventing unnecessary re-renders. - Call
reset()after successful submission when the form stays mounted (e.g., modals that reopen). - Set
shouldUnregister: false(the default) for wizard/multi-step forms so that navigating between steps preserves values.
Anti-Patterns
- Mixing controlled state with register — do not use
useStatealongsideregisterfor the same input. This causes double sources of truth and subtle bugs. UsewatchoruseWatchif you need to read a value reactively. - Using array index as
key— always use thefield.idprovided byuseFieldArrayas the React key. Index-based keys cause inputs to lose their values when items are reordered or removed. - Calling
setValuein a render loop — this triggers re-renders. Move it intouseEffector event handlers. - Skipping
noValidateon the form element — without it, native browser validation tooltips appear alongside your custom error messages, confusing users. - Overusing
trigger()— manually triggering validation on every change defeats the performance benefit of the library. Trust the configuredmodeandreValidateMode. - Forgetting
as conston template literal field names — without it, TypeScript cannot narrow the type for nested or array paths, losing type safety.
Install this skill directly: skilldb add forms-validation-skills
Related Skills
Conform
Conform — progressive enhancement forms, Server Actions, Zod integration, nested objects, arrays, intent buttons, and accessibility patterns
Formik
Formik — useFormik, Field, Form, validation with Yup, FieldArray, custom inputs, submission handling, and error display patterns
React Final Form
React Final Form — subscription-based form state management with fine-grained re-renders, field-level validation, decorator support, and zero dependencies
Valibot
Valibot — modular schema validation, tiny bundle size, parse/safeParse, pipe transformations, type inference, and comparison with Zod
Vest
Vest — unit-test-inspired form validation framework with suite/test syntax, async rules, group support, warning-level validations, and framework-agnostic design
Yup
Yup — schema-based object validation with casting, transforms, conditional rules, localized error messages, and deep Formik integration