React Hook Form Patterns
Quick Guide:
registerbinds native inputs and keeps them uncontrolled;Controllerwraps components that hold their own value;useFieldArraydrives repeatable rows and is keyed onfield.id. Validation arrives either asregisterrules or as a schema throughresolver. Re-renders are the thing to watch:useWatchanduseFormStatesubscribe to named fields, whereaswatch()and a wideformStatedestructure subscribe to the whole form.
Detailed Resources:
- examples/core.md — a type-safe form with
register, error display and accessibility attributes - examples/controlled-components.md —
Controlleraround a select, a date picker and a checkbox group - examples/validation.md — wiring a schema in through
resolver - examples/arrays.md —
useFieldArrayline items with a live total - examples/performance.md — isolated subscriptions,
FormStateSubscribe,useWatchexactandcompute - examples/form-options.md — the
valuesprop for async data,disabled, the<Form />component - examples/wizard.md — multi-step form with per-step
trigger() - reference.md — decision trees, API tables, version-to-feature lookup
Which path applies
- A native
input,selectortextarea—registerhands 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
ControllersuppliesvalueandonChangeand confines the re-render to that field. Follow examples/controlled-components.md. - Validation stated as a schema rather than as
registerrules — pass it throughresolverand the field-levelrulesdrop out. Follow examples/validation.md.
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.
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 —
resolveraccepts 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
valuesoption and fetches nothing itself. - Markup and styling of the inputs — every example uses plain elements, so the class names are yours.
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.
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" => 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
<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
Red flags
Breaks at runtime:
- An array index as the
useFieldArraykey — React matches the wrong rows, so removing a middle item shifts every value below it up one. Key onfield.id. registeron 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 inController.- 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,prependorinsert— the absent keys arrive asundefinedand their inputs read as uncontrolled. Pass a complete item. - An error thrown inside
onSubmit—handleSubmitdoes not catch it, so the rejection escapes unhandled. Catch inside the callback. setValueagainst a field array's own name — the row ids do not move with the values. Usereplace().
Surprising behaviour:
- No generic on
useFormleaves field paths and the submit payload asany, soregister("emial")is accepted in silence. - Destructuring several
formStateproperties subscribes to all of them, and the form then re-renders on any change. Take only what the component reads, or isolate it withuseFormState. watch()in a render body subscribes to every field;useWatchin a child narrows it to named ones.setValuewithoutshouldValidate: trueleaves the previous error on screen.- Array-level errors sit at
errors.items.root; per-item errors aterrors.items[index].field. shouldUnregister: truediscards the values of unmounted fields. Leave itfalse(the default) for anything that hides fields, wizards especially.useWatchreturns itsdefaultValueon the first render, before the subscription attaches.valuesis for external data that keeps changing anddefaultValuesfor static initial values; supplying both makes which one wins depend onresetOptions.- Per-step validation needs
trigger(fieldNames)—isValidreflects 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.