Install with Codex or Claude Copy this prompt, paste it into Codex, Claude, or another assistant, and let it review the skill page and install it for you.
A direct command skips the review prompt. Inspect the source before running it.
Loaded automatically when its description matches the active task. Read only the section you need, then follow the link to the relevant reference file for full detail.
Use this skill when
Building any form with useForm, register, handleSubmit, or Controller
Integrating RHF with Zod via @hookform/resolvers/zod for schema-based validation
Setting up useFieldArray for dynamic lists (add/remove/reorder fields at runtime)
Using FormProvider + useFormContext to share form state in deeply nested components
Implementing multi-step forms with partial schemas and inter-step state
Merging server-side validation errors (e.g., API 422 responses) into formState.errors
Integrating RHF with shadcn/ui Form, FormField, FormItem, FormMessage primitives
Wiring RHF with TanStack Query useMutation for async form submission
Handling conditional fields that appear/disappear based on other field values
Implementing async validation (debounced uniqueness checks, server lookups)
Resetting forms after submission, including partial reset and defaultValues
Do not use this skill when
Building forms with Formik — different library, different mental model
Using TanStack Form (react-form) — different API, not RHF
Working with plain uncontrolled HTML forms without any form library
Task is Zod schema design only (no form context) — use zod skill
Task is pure React state management without form submission — use react skill
Task is shadcn/ui component theming only — use shadcn skill
Purpose
React Hook Form v7 achieves form performance through uncontrolled inputs: fields read their value from the DOM at submit time rather than re-rendering on every keystroke. This makes RHF substantially faster than controlled approaches (Formik, Redux Form) in forms with many fields, especially when using watch selectively. The skill covers the complete RHF production lifecycle: schema setup, register API, Controller for third-party inputs, field arrays, multi-step orchestration, and server error injection.
The primary integration target is Zod v4 via @hookform/resolvers/zod — schema inference eliminates duplicate type declarations. For UI, the shadcn/ui Form component wraps RHF's context cleanly. TanStack Query mutations compose naturally with handleSubmit.
Capabilities
useForm Setup and Modes
useForm<TSchema>({ resolver: zodResolver(schema), defaultValues, mode }) is the root call. Mode controls when validation runs:
mode
When validation fires
onSubmit
Only on submit (default — best perf)
onBlur
After field loses focus
onChange
On every keystroke (expensive — avoid for large forms)
onTouched
On first blur, then onChange
all
onChange + onBlur
reValidateMode (default: onChange) controls re-validation after the first submit. Use mode: 'onBlur' + reValidateMode: 'onBlur' for most UX needs without keystroke-level re-renders.
FormProvider passes methods (the useForm return) through React context. Child components call useFormContext<TSchema>() to access register, control, formState, setValue, getValues, etc. Prevents prop-drilling in large forms with nested components. Type the schema at useFormContext<TSchema> for full inference.
useFieldArray({ control, name }) returns { fields, append, prepend, remove, insert, move, swap, update }. Fields have a stable RHF-generated id — always use field.id as React key, not the array index. Nested arrays (array of arrays) require nested useFieldArray calls each with their own name path.
zodResolver(schema) from @hookform/resolvers/zod runs schema.safeParse on submit (and on the configured mode triggers). The resolver infers TSchema from z.infer<typeof schema> — use useForm<z.infer<typeof schema>> to make TypeScript aware of the shape. Partial validation (multi-step): pass schema.pick({...}) as the resolver for each step.
Pattern: single useForm at root, navigate steps without unmounting, validate each step's fields via trigger(['field1', 'field2']) before proceeding, submit full payload on final step. FormProvider shares the form across step components. Avoid unmounting steps — unmounted fields clear their values unless shouldUnregister: false is set (it's false by default in RHF v7+).
After an API call returns validation errors (e.g., 422 with { field: string, message: string }[]), use setError('fieldName', { type: 'server', message }) to inject them into formState.errors. For a root-level form error (non-field), use setError('root', { message }). Call clearErrors() or individual clearErrors('fieldName') on user correction.
formState is proxy-based — only the fields you destructure trigger re-renders. Destructure only what you need: const { errors, isSubmitting, isValid } = formState. Common pitfalls: isValid is false until the form has been validated at least once (use mode: 'onChange' or call trigger() if you need isValid on mount). isDirty compares against defaultValues — always provide defaultValues for it to work correctly.
watch('fieldName') re-renders the component on every change to that field. useWatch({ control, name }) is the same but can be used in child components and is slightly more performant (subscription-based). For side effects on field change, use useEffect(() => { const sub = watch(callback); return sub.unsubscribe; }, [watch]). Never call watch() (no args) in a large form — it subscribes to all fields.
Always provides defaultValues in useForm — required for isDirty, reset, and diff tracking
Uses zodResolver as the sole validation layer — never duplicates constraints in register options when a schema resolver is present
Uses Controller for any non-native input (shadcn Select, Checkbox, DatePicker, custom pickers)
Spreads field from Controller.render onto the UI component — never manually wires onChange/value
Destructs only the needed formState fields — avoids formState.errors + formState.isValid + formState.isDirty all at once unless all three are actually used
Uses useWatch over watch in child components — avoids re-rendering the root form component
Calls trigger(['fieldA', 'fieldB']) to validate a specific step before advancing — never validates the entire form mid-flow
Uses setError('root', ...) for non-field API errors (e.g., "email already taken" at form level)
Calls reset(newValues) after successful submission to clear dirty state — not reset() without args unless intentionally clearing to defaultValues
Prefers onBlur mode for UX — onChange is reserved for real-time search or uniqueness-check flows
Important Constraints
NEVER use array index as React key in useFieldArray renders — always field.id
NEVER call watch() with no arguments in large forms — subscribes to all fields, causes constant re-renders
NEVER add Zod validation AND inline register constraints for the same field — the resolver owns validation
NEVER access formState.isValid expecting true on initial render in onSubmit mode — it starts false
NEVER use shouldUnregister: true unless explicitly needed — unmounted fields lose their values
ALWAYS provide defaultValues with useForm — absent defaults cause uncontrolled-to-controlled warnings and break isDirty
ALWAYS use <FormProvider> with useFormContext — never pass control/register as props through many levels
ALWAYS handle the isSubmitting state to disable the submit button — prevents double-submission
ALWAYS type useFormContext<z.infer<typeof schema>>() — untyped context loses field inference
ALWAYS use async on handleSubmit callback when calling async APIs — RHF sets isSubmitting: true only for async handlers
Wrong vs right — Controller vs register, field.id vs index, duplicate validation, missing defaultValues, scoped useWatch, async onSubmit, setError, FormProvider