React Hook Form patterns - useForm, Controller, useFieldArray, validation resolver, performance optimization
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:
register, error display and accessibility attributesController around a select, a date picker and a checkbox groupresolveruseFieldArray line items with a live totalFormStateSubscribe, useWatch exact and computevalues prop for async data, disabled, the <Form /> componenttrigger()input, select or textarea — register hands the ref to the form, the field
stays uncontrolled, and typing re-renders nothing. Follow examples/core.md.Controller supplies value and onChange and confines the re-render to that
field. Follow examples/controlled-components.md.register rules — pass it through resolver and
the field-level rules drop out. Follow examples/validation.md.<critical_requirements>
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:
Handled elsewhere:
resolver accepts a schema object and this skill only wires it
in; how the schema states its rules is settled by whatever owns it.values
option and fetches nothing itself.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.
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
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
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
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
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
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
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
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:
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.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.append, prepend or insert — the absent keys arrive as undefined
and their inputs read as uncontrolled. Pass a complete item.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:
useForm leaves field paths and the submit payload as any, so register("emial")
is accepted in silence.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.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.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>
npx skills add agents-inc/web-forms-react-hook-form下载完整 Skill 目录,包含 SKILL.md 及所有相关文件
Search for places (restaurants, cafes, etc.) via Google Places API proxy on localhost.
Interact with GitHub using the `gh` CLI. Use `gh issue`, `gh pr`, `gh run`, and `gh api` for issues, PRs, CI runs, and advanced queries.
Create or update AgentSkills. Use when designing, structuring, or packaging skills with scripts, references, and assets.
Start voice calls via the OpenClaw voice-call plugin.
Notion API for creating and managing pages, databases, and blocks.
Gemini CLI for one-shot Q&A, summaries, and generation.
Category:developer