React Hook Form Patterns
Quick Guide: register binds native inputs and keeps them uncontrolled; Controller wraps
components that hold their own value; useFieldArray drives repeatable rows and is keyed on
field.id. Validation arrives either as register rules or as a schema through resolver.
Re-renders are the thing to watch: useWatch and useFormState subscribe to named fields,
whereas watch() and a wide formState destructure subscribe to the whole form.
Detailed Resources:
Which path applies
- A native
input, select or textarea — register hands the ref to the form, the field
stays uncontrolled, and typing re-renders nothing. Follow examples/core.md.
- A component that owns its value — a custom select, date picker or rich text editor exposes no
usable ref, so
Controller supplies value and onChange and confines the re-render to that
field. Follow examples/controlled-components.md.
- Validation stated as a schema rather than as
register rules — pass it through resolver and
the field-level rules drop out. Follow examples/validation.md.
<critical_requirements>
Before writing React Hook Form code
Call useForm<FormData>() with a generic and with defaultValues for every field. The generic
types each field path and the submit payload; the defaults mount every input controlled from the
first render.
Key useFieldArray rows on field.id. It is the identity RHF assigns the row and it survives
add, remove and reorder, which an array index does not.
Reach for Controller as soon as a component holds its own value. register needs a ref that
reaches a native input, and a component that does not forward one never joins the form.
Set mode to "onBlur" or "onTouched". The default "onSubmit" withholds feedback until the
first submit, and "onChange" validates on every keystroke.
Pass schema validation through resolver, with the schema in its own module. A schema outside
the component is testable on its own and reusable across forms, and the form keeps only the wiring.
</critical_requirements>
Auto-detection: react-hook-form, useForm, register, handleSubmit, formState, Controller,
useFieldArray, useWatch, useFormContext, useFormState, FormProvider, FormStateSubscribe, SubmitHandler,
resolver, shouldUnregister, valueAsNumber
Applies to:
- Form state, submission and validation wiring in React
- Repeatable field groups that add, remove and reorder
- Components that hold their own value and need bridging into the form
- Multi-step flows where one form spans several screens
- Narrowing re-renders in a form with many fields
Handled elsewhere:
- Authoring the validation schema —
resolver accepts a schema object and this skill only wires it
in; how the schema states its rules is settled by whatever owns it.
- Where the initial values came from — the form receives them as a prop or through the
values
option and fetches nothing itself.
- Markup and styling of the inputs — every example uses plain elements, so the class names are yours.
<philosophy>
Form state lives outside React state. Inputs register themselves with the form and report through a
ref, so a keystroke updates the form's own store without re-rendering the component that owns the
field. Everything that reads form state — an error message, a computed total, a submit button — opts
in by subscribing to a named slice, and a component that subscribes to nothing never re-renders.
This is why the wide reads cost so much. watch() in a render body and a formState destructure
that pulls six properties both subscribe to the entire form, undoing the isolation the library was
built for.
</philosophy>
<patterns>
Core patterns
Pattern 1: useForm with a generic
The generic, mode and defaultValues together settle type safety, validation timing and
controlled-input warnings.
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<ContactFormData>({
mode: "onBlur",
defaultValues: { name: "", email: "", message: "" },
});
Full code: examples/core.md
Pattern 2: Controller for components that own their value
Controller renders the field itself and hands it value and onChange. The test for which to
reach for: a component whose ref forwards to a native input works with register, and anything
else needs Controller.
<Controller
name="service"
control={control}
rules={{ required: "Service is required" }}
render={({ field, fieldState: { error } }) => (
<>
<Select {...field} options={serviceOptions} />
{error && <span role="alert">{error.message}</span>}
</>
)}
/>
Full code: examples/controlled-components.md
Pattern 3: useFieldArray for repeatable rows
fields carries a generated id per row, and that is the React key. Array-level rules go on the
hook, and their errors land at errors.items.root.
const { fields, append, remove } = useFieldArray({ control, name: "items" });
{fields.map((field, index) => (
<div key={field.id}>
<input {...register(`items.${index}.name`)} />
<button type="button" onClick={() => remove(index)}>Remove</button>
</div>
))}
Full code: examples/arrays.md
Pattern 4: Schema validation through resolver
resolver replaces the per-field rules: the schema decides what is valid and reports errors
against field paths, and the form does the wiring.
const { register, handleSubmit } = useForm<FormData>({
resolver: zodResolver(schema),
mode: "onBlur",
defaultValues: { username: "", email: "" },
});
A schema kept in its own module is testable without rendering, reusable across forms, and can state
cross-field rules — matching passwords, a date range — that per-field rules cannot express.
Full code: examples/validation.md
Pattern 5: useWatch for derived values
useWatch in a child component subscribes to named fields, so only that child re-renders when they
change.
function PriceDisplay({ control }: { control: Control<PricingFormData> }) {
const [plan, seats] = useWatch({ control, name: ["plan", "seats"] });
return <div>Total: ${PLAN_PRICES[plan] * seats}</div>;
}
The compute option narrows the subscription further — the component re-renders when the computed
result changes rather than when an input to it does.
Full code: examples/performance.md Pattern 11 and Pattern 12
Pattern 6: useFormContext for nested fields
FormProvider puts the form methods on context so a nested or reused section reaches them without
prop drilling. Worth it at three levels of nesting or for a section rendered more than once; below
that, passing control is simpler.
<FormProvider {...methods}>
<form onSubmit={methods.handleSubmit(onSubmit)}>
<AddressFields prefix="shippingAddress" />
<AddressFields prefix="billingAddress" />
</form>
</FormProvider>
function AddressFields({ prefix }) {
const { register } = useFormContext<CheckoutFormData>();
return <input {...register(`${prefix}.street`)} />;
}
Full code: examples/wizard.md
Pattern 7: Loading external data
values is reactive — the form follows the data as it changes — while defaultValues is read once
on mount. Pair values with resetOptions: { keepDirtyValues: true } so a background refresh does
not discard what the user has typed.
useForm<FormData>({
values: userData,
resetOptions: { keepDirtyValues: true },
});
After a successful save, reset(data) replaces the values and the defaults together, which is what
clears isDirty. reset() with no argument reverts to the original defaults — the cancel button.
Full code: examples/form-options.md Pattern 7
Pattern 8: Isolated error display
useFormState with a name re-renders only when that field's state changes, which keeps an error
message from re-rendering the form around it.
function FieldError<T extends FieldValues>({ control, name }: Props<T>) {
const { errors } = useFormState({ control, name });
const error = errors[name];
if (!error) return null;
return <span role="alert">{error.message as string}</span>;
}
Full code: examples/performance.md — Pattern 5 for the hook, Pattern 10
for the FormStateSubscribe component form
</patterns>
<red_flags>
Red flags
Breaks at runtime:
- An array index as the
useFieldArray key — React matches the wrong rows, so removing a middle
item shifts every value below it up one. Key on field.id.
register on a component that holds its own value — no ref arrives, the field never registers,
and its value is absent from the submit payload. Wrap it in Controller.
- No
defaultValues — inputs mount uncontrolled and flip to controlled on the first keystroke,
which React warns about and SSR reports as a hydration mismatch. Seed every field, "" included.
- A partial object handed to
append, prepend or insert — the absent keys arrive as undefined
and their inputs read as uncontrolled. Pass a complete item.
- An error thrown inside
onSubmit — handleSubmit does not catch it, so the rejection escapes
unhandled. Catch inside the callback.
setValue against a field array's own name — the row ids do not move with the values. Use
replace().
Surprising behaviour:
- No generic on
useForm leaves field paths and the submit payload as any, so register("emial")
is accepted in silence.
- Destructuring several
formState properties subscribes to all of them, and the form then
re-renders on any change. Take only what the component reads, or isolate it with useFormState.
watch() in a render body subscribes to every field; useWatch in a child narrows it to named ones.
setValue without shouldValidate: true leaves the previous error on screen.
- Array-level errors sit at
errors.items.root; per-item errors at errors.items[index].field.
shouldUnregister: true discards the values of unmounted fields. Leave it false (the default)
for anything that hides fields, wizards especially.
useWatch returns its defaultValue on the first render, before the subscription attaches.
values is for external data that keeps changing and defaultValues for static initial values;
supplying both makes which one wins depend on resetOptions.
- Per-step validation needs
trigger(fieldNames) — isValid reflects the whole form, so a wizard
gated on it is stuck on step one.
Worked before/after code for the most common of these is in reference.md.
</red_flags>