
React Hook Form
OfficialFreeEnsure correct usage of React Hook Form in your codebase.
Free · Opens the source repo
What React Hook Form does
The React Hook Form skill provides guidelines for correctly implementing forms in React applications, particularly when using the React Hook Form library. It is designed to address common anti-patterns found in existing codebases, ensuring that developers do not inadvertently replicate issues that can lead to performance degradation and bugs. By following the instructions laid out in this skill, developers can maintain a clean and efficient form handling strategy as their applications grow and evolve.
This skill emphasizes the importance of understanding how subscriptions work within React Hook Form. It clarifies that certain methods, such as form.watch(), can cause unnecessary re-renders across the entire form tree, leading to performance issues. Instead, it encourages the use of more efficient subscription methods like useWatch and useFormState, which help isolate re-renders to only the components that need to update. This approach not only enhances performance but also ensures that form state remains accurate and responsive to user input.
The skill also outlines best practices for defining form schemas and managing default values, highlighting the need for complete defaultValues objects to prevent unexpected behavior. It provides concrete examples of how to structure forms using the React Hook Form API, ensuring that developers can implement forms that are both functional and maintainable. By adhering to these guidelines, developers can avoid common pitfalls and create forms that are robust and easy to work with, even as requirements change over time.
When to use it
Use this skill when writing or modifying any form code in a React application that utilizes React Hook Form, especially in a codebase with existing anti-patterns.
When not to use it
This skill may not be necessary for simple forms or when working in a codebase that already adheres to best practices for React Hook Form usage.
What you can build with it
Upgrading Existing Forms
When modifying an existing form, use this skill to ensure that changes adhere to best practices and avoid introducing new anti-patterns.
Creating New Forms
Utilize this skill as a reference when building new forms to ensure they are structured correctly from the start.
Debugging Form Issues
Refer to this skill when troubleshooting form-related bugs to identify potential misuse of React Hook Form methods.
How to install React Hook Form
View source1. Install with the skills CLI
npx skills add supabase/supabase/react-hook-form --agent claude-code2. Or install it manually
Download the skill folder and drop it into ~/.claude/skills/ for all projects, or .claude/skills/ to scope it to one repo. Restart Claude Code so it picks up the new skill.
Anthropic's agentic coding CLI, and the reference implementation of Agent Skills. Drop a skill folder into ~/.claude/skills and Claude Code loads it automatically whenever a task matches the skill's description. Claude Code docs
Inside SKILL.md
Written by supabaseReact Hook Form
How to write forms that stay correct as they grow. The existing codebase is not
a safe reference: form.watch() off prop-drilled form objects, subscription-only
watches, unguarded valueAsNumber, and ?? undefined controlled values are all
common in older code and all wrong. Follow this skill, not the neighboring file.
Policy — fix what you touch. New code must follow these rules. When you modify
existing form code, upgrade the specific fields/hooks/components you're editing to
match (e.g. a component you touch that calls form.watch gets converted to
useWatch). Leave untouched code alone, but tell the user about anti-patterns you
noticed and didn't fix. Never add new violations: react-hook-form/no-use-watch
is ratcheted in Studio CI — any increase in the warning count fails the build.
Mental model: subscriptions decide who re-renders
RHF is uncontrolled at heart. Values live in refs; nothing re-renders unless a subscription says so. Every read API is a subscription decision:
| API | Subscribes | Re-renders | Use for |
|---|---|---|---|
useWatch({ control, name }) | yes | only the calling component | reactive value reads, anywhere |
useFormState({ control }) | yes | only the calling component | isDirty/errors/etc. outside the form owner |
formState (destructured) | yes | the useForm owner | form state in the owner component only |
form.watch(name) | yes | the entire form tree | avoid — lint-flagged, see below |
getValues() | no | never | event handlers and onSubmit only |
subscribe() | callback | none | side effects outside render |
Two facts explain most of the bugs we've shipped:
form.watch()andform.formStatehoist their subscription to theuseFormowner, no matter which component calls them. A child that readsform.watch('x')off a prop works today only because the whole tree re-renders on every change — it silently goes stale the moment anyone addsReact.memobetween owner and child, and until then it re-renders every sibling on every keystroke. A no-argform.watch()setswatchAlland re-renders the tree on every field change for the life of the form.formStateis a Proxy — reading a property is what arms the subscription. Destructure it (const { isDirty } = form.formState), never pass the object around or read it conditionally (a && formState.isValidmay never subscribe). Enforced byreact-hook-form/destructuring-formstate(error).
Reading values, by location
- In the component that owns
useForm: destructureformState; preferuseWatchoverform.watcheven here (theno-use-watchrule flags everywatch, anduseWatchscopes the re-render if the JSX is later extracted). - In any child component or custom hook: accept
control(not the wholeform) and useuseWatch({ control, name })/useFormState({ control }). Inside<Form {...form}>(which isFormProvider),useFormContext()+useWatch({ name })also works and avoids prop-drilling entirely. - Consume the return value. Never call a watch for its subscription side
effect and then read via
getValues()— the watch list and the read list will drift apart (it has already happened; fields silently lost reactivity). The value you render must be the value you subscribed to. - One read path per value per render. Mixing
useWatch('x')on one line andgetValues('x')a few lines later lets the two disagree within a single render. - Name what you watch.
useWatch({ control })with nonamere-renders on every keystroke in every field. Subscribe to the specific names you use. watch(callback)is deprecated — usesubscribe()for render-free listeners, and always return its cleanup fromuseEffect.
// ❌ common in the codebase — all three subscriptions hoist to the form owner
function Fields({ form }: { form: UseFormReturn<FormValues> }) {
form.watch(['storageType', 'totalSize']) // return value discarded
const { errors } = form.formState // prop-form formState
const size = form.getValues('totalSize') // non-reactive read in render
...
}
// ✅ child subscribes for itself and consumes what it watches
function Fields({ control }: { control: Control<FormValues> }) {
const [storageType, totalSize] = useWatch({ control, name: ['storageType', 'totalSize'] })
const { errors } = useFormState({ control })
...
}
The canonical form
zod schema → z.infer type → useForm with zodResolver and complete
defaultValues → <Form {...form}> → FormField render-prop per field →
FormItemLayout → FormControl → primitive from ui. Layout/container choices
(Card vs Sheet, layout= variants) are covered by the studio-ui-patterns skill
and the demos in apps/design-system/registry/default/example/
(form-patterns-pagelayout.tsx, form-patterns-sidepanel.tsx) — check them
before inventing structure.
// Module level — static references, not recreated on every render
const FORM_ID = 'pool-config-form'
const FormSchema = z.object({
name: z.string().min(1, 'Name is required'),
maxConnections: z
.union([z.literal(''), z.coerce.number().gte(1, 'Must be at least 1')])
.refine((v) => v !== '', 'Max connections is required'),
})
type FormValues = z.infer<typeof FormSchema>
const defaultValues: FormValues = { name: '', maxConnections: '' }
// Inside the component
const form = useForm<FormValues>({
resolver: zodResolver(FormSchema),
defaultValues,
})
<Form {...form}>
<form id={FORM_ID} onSubmit={form.handleSubmit(onSubmit)}>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItemLayout layout="horizontal" label="Name">
<FormControl>
<Input {...field} />
</FormControl>
</FormItemLayout>
)}
/>
</form>
</Form>
Define the schema, type, static defaultValues, and the form's id at module
level, outside the component. Rebuilding them per render is wasted work and
unstable references — RHF reads defaultValues only on the first render, but
anything else comparing against these objects sees a fresh identity each time.
When they genuinely depend on runtime data, build the schema with useMemo and
feed server-driven defaults through the values option (next section) instead
of hoisting.
Submit buttons living outside the <form> (sheet/dialog footers) use the same
module-level FORM_ID via form={FORM_ID} on the button. A module-level id is
only safe for singleton forms — if the component can mount more than once at a
time, duplicate ids make external buttons submit the first matching form, so
mint a per-instance id with useId() and share it between the <form> and its
buttons.
defaultValues, server data, and reset
- Provide a complete
defaultValuesobject — every field, noundefined.isDirty,dirtyFields, and Cancel-reset all compare against it; a missing orundefineddefault breaks all three, andundefinedalso makes React treat the input as uncontrolled (see below). - Form populated from an API? Use the
valuesoption, not a hand-rolled effect.valuesreacts to the query resolving and resets the form for you; computingdefaultValuesfrom a query that may not have loaded freezes whatever happened to be in cache at mount. AddresetOptions: { keepDirtyValues: true }when a background refetch must not clobber the user's in-progress edits. (Good examples:components/interfaces/Settings/Database/ConnectionLogging.tsx,components/interfaces/Storage/EditBucketModal.tsx.) - After a successful mutation, re-baseline the form in
onSuccessso the saved state becomes the new baseline (isDirtyreturns to false, Cancel now reverts to the saved values). Prefer what the server actually persisted: if the form usesvaluesand the mutation invalidates the query, the refetch handles this for you; if the mutation returns the updated resource,reset(response).reset(submittedValues)is the fallback for APIs that store exactly what was sent — if the server normalizes or fills values, it baselines the form to data that was never saved. A barereset()reverts to the previous defaults — wrong after a save. - Cancel buttons call
form.reset(). This only visually restores fields whose values round-trip through defined, controlled values — which is why the null rules below matter.
Controlled inputs: never let value flip to undefined
React decides controlled vs uncontrolled per render from whether value is
defined. A field whose value can be undefined (or becomes undefined on reset)
flips modes: console warnings, and — worse — reset() stops clearing the visible
text because React abandoned the DOM value. value={field.value ?? undefined} is
a bug, not a fix.
- Text fields: default to
'', nevernull/undefined. - Normalize
nullfrom the API at the form boundary (growthPercent ?? ''when building defaults) and convert back on submit ('' → null). Do not paper over anulldefault with aplaceholderthat looks like a value: the user sees "50", the form holdsnull, and every downstream comparison (defaultValues.growthPercent !== watched→null !== 50) reports a permanent phantom change while Cancel silently fails to reset the field. - Selects/radios: default to
''or a real option value; checkboxes/switches tofalse.
Number inputs
The blessed pattern keeps '' as the "empty" sentinel so the input stays
controlled, and lets zod coerce on validation (see maxConnections above):
z.union([z.literal(''), z.coerce.number()...]).refine((v) => v !== '', '…')
with a plain <Input {...field} type="number" />.
If you instead wire onChange through e.target.valueAsNumber (or
valueAsNumber: true), an empty or partially-typed input produces NaN, which
lands in form state and propagates into every calculation, price preview, and
value attribute downstream. Guard it with the same empty sentinel the
field's schema declares — with the ''-union schema above:
field.onChange(Number.isNaN(e.target.valueAsNumber) ? '' : e.target.valueAsNumber).
Never let NaN into form state.
A nullable API field (null = "unset", e.g. a platform default applies)
doesn't change the in-form sentinel — keep '' inside the form and convert at
the boundaries:
// inbound: null → '' when building defaults/values
values: { growthPercent: data.growth_percent ?? '' },
// schema: '' stays the in-form sentinel, zod coerces real input
growthPercent: z.union([z.literal(''), z.coerce.number().gte(10).lte(100)]),
// outbound: '' → null in onSubmit
mutate({ growth_percent: values.growthPercent === '' ? null : values.growthPercent })
If null does end up in form state (some existing forms hold it), keep it out
of both the input and the coercion: render via value={field.value ?? ''}, and
don't pass the value through z.coerce.number() — Number(null) is 0, so a
nullable field fed into the coercing union silently validates empty as 0.
Either way it's one sentinel per field, used consistently across defaults,
schema, onChange, rendering, and the submit mapping.
Dirty state and change detection
- Gate Save on
isDirty; show Cancel only when dirty. In the owner, destructure fromform.formState; anywhere else,useFormState({ control }). - When the form lives in a Sheet or Dialog, also wire dirty dismissal:
useConfirmOnClose+DiscardChangesConfirmationDialog. Route Cancel, Escape, and backdrop through the guard; call the rawonCloseon successful submit so you do not prompt after save. Details:apps/design-system/content/docs/ui-patterns/modality.mdx(Dirty form dismissal) and the studio-ui-patterns skill Sheets section. - To show which fields changed (review/summary dialogs), read
dirtyFieldsfrom the same subscription instead of hand-comparingdefaultValues.x !== watchedX. RHF already does that comparison correctly; hand-rolled versions break on the null-vs-placeholder mismatch and must be kept in sync with the watch list by hand. setValueoutside user input needs explicit flags:setValue('x', v, { shouldDirty: true, shouldValidate: true })— otherwise the change is invisible toisDirtyand validation.
Disabling and gating
If a field must not be edited (plan tier, permissions, cooldown), disable the
field itself — a notice next to an editable input gates nothing. Wire the same
condition into both the notice and the control. Permission checks come from
useAsyncCheckPermissions; disabled buttons that need an explanation use
ButtonTooltip.
Caution: register/useController disabled: true removes the field's value
from submission data. For "visible but locked" fields whose value must survive
submit, use the input's own disabled/readOnly prop (as FormField +
primitive props do) rather than RHF-level disabling, or the form-level
disabled option to freeze everything during async work.
Submit and mutations
onSubmit receives validated, typed data — trust it; don't re-read via
getValues(). Mutations follow Studio conventions: onSuccess → toast.success
reset(values)(or query invalidation when usingvalues:),onError→toast.error; pass the mutation'sisPendingto the button'sloadingprop. Default validationmode: 'onSubmit'is right for most forms — pick another mode deliberately, not by copying.
Lint rules in force (Studio)
| Rule | Level | Meaning |
|---|---|---|
react-hook-form/destructuring-formstate | error | destructure formState, never hold the object |
react-hook-form/no-access-control | error | don't reach into control internals |
react-hook-form/no-nested-object-setvalue | error | setValue('a.b', v), not setValue('a', {b:v}) |
react-hook-form/no-use-watch | warn (ratcheted) | use useWatch, not watch |
Frequently asked questions about React Hook Form
Similar skills
Playwright Component Testing
Test React and Vue components in isolation with Playwright.
Fluent UI Blazor
Integrate Fluent UI components in Blazor applications effortlessly.
Build MCP App
Create interactive UI widgets for MCP servers.
Web Design Reviewer
Identify and fix design issues in websites efficiently.
Markstream Install
Seamlessly integrate Markstream for Markdown rendering.
GSAP & Framer Scroll Animation
Create advanced scroll animations effortlessly.
