Skip to main content

react-hook-form

Correct React Hook Form usage anywhere in the monorepo — data flow, subscriptions, reset, dirty state, number inputs, and controlled-input rules. Load this BEFORE writing or modifying ANY form code, adding a field to an existing form, touching watch/useWatch/formState/getValues/setValue/reset, wiring a form into a dialog or sheet, or building a submit/cancel footer — even when the change looks trivial. The codebase contains widespread RHF anti-patterns; without this skill you will copy them. For form layout and which components to use, also load studio-ui-patterns.

ソース情報

リポジトリ
supabase/supabase
ソースの最終更新活動
2026年10月6日 15:29
検出された SKILL.md の言語
英語
スター
114,287
フォーク
16,332

インストール方法

デフォルトでは、最初にソースを確認する Prompt が選択されています。直接コマンドに切り替えるか、ローカルコピーをダウンロードすることもできます。

ソースファイルを確認

インストールを決める前に、SKILL.md と SkillsMP に表示されている付属ファイルをお読みください。

SKILL.md を表示中

SKILL.md
ソースの指示 · 読み取り専用プレビュー
name
react-hook-form
description
Correct React Hook Form usage anywhere in the monorepo — data flow, subscriptions, reset, dirty state, number inputs, and controlled-input rules. Load this BEFORE writing or modifying ANY form code, adding a field to an existing form, touching watch/useWatch/formState/getValues/setValue/reset, wiring a form into a dialog or sheet, or building a submit/cancel footer — even when the change looks trivial. The codebase contains widespread RHF anti-patterns; without this skill you will copy them. For form layout and which components to use, also load studio-ui-patterns.
# React 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: 1. **`form.watch()` and `form.formState` hoist their subscription to the `useForm` owner**, no matter which component calls them. A child that reads `form.watch('x')` off a prop works today only because the whole tree re-renders on every change — it silently goes stale the moment anyone adds `React.memo` between owner and child, and until then it re-renders every sibling on every keystroke. A no-arg `form.watch()` sets `watchAll` and re-renders the tree on every field change for the life of the form. 2. **`formState` is 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.isValid` may never subscribe). Enforced by `react-hook-form/destructuring-formstate` (error). ### Reading values, by location - **In the component that owns `useForm`:** destructure `formState`; prefer `useWatch` over `form.watch` even here (the `no-use-watch` rule flags every `watch`, and `useWatch` scopes the re-render if the JSX is later extracted). - **In any child component or custom hook:** accept `control` (not the whole `form`) and use `useWatch({ control, name })` / `useFormState({ control })`. Inside `<Form {...form}>` (which _is_ `FormProvider`), `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 and `getValues('x')` a few lines later lets the two disagree within a single render. - **Name what you watch.** `useWatch({ control })` with no `name` re-renders on every keystroke in every field. Subscribe to the specific names you use. - `watch(callback)` is deprecated — use `subscribe()` for render-free listeners, and always return its cleanup from `useEffect`. ```tsx // ❌ 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. ```tsx // 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 `defaultValues` object — every field, no `undefined`.** `isDirty`, `dirtyFields`, and Cancel-reset all compare against it; a missing or `undefined` default breaks all three, and `undefined` also makes React treat the input as uncontrolled (see below). - **Form populated from an API? Use the `values` option, not a hand-rolled effect.** `values` reacts to the query resolving and resets the form for you; computing `defaultValues` from a query that may not have loaded freezes whatever happened to be in cache at mount. Add `resetOptions: { keepDirtyValues: true }` when a background refetch must not clobber the user's in-progress edits. `keepDirtyValues` preserves whatever is in `formState.dirtyFields`; typed edits and `setValue(…, { shouldDirty: true })` populate it regardless of subscriptions, but `useFieldArray` operations (append/remove/move) only mark fields dirty while `dirtyFields` or `isDirty` is subscribed — so a form that combines `keepDirtyValues` with a field array must read one of them in the owner, or the next refetch will discard array edits. (Good examples: `components/interfaces/Settings/Database/ConnectionLogging.tsx`, `components/interfaces/Storage/FilesBuckets/EditBucketModal.tsx`.) - **After a successful mutation, re-baseline the form** in `onSuccess` so the saved state becomes the new baseline (`isDirty` returns to false, Cancel now reverts to the saved values). Prefer what the server actually persisted: if the form uses `values` and 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 bare `reset()` 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 `''`, never `null`/`undefined`. - **Normalize `null` from the API at the form boundary** (`growthPercent ?? ''` when building defaults) and convert back on submit (`'' → null`). Do not paper over a `null` default with a `placeholder` that looks like a value: the user sees "50", the form holds `null`, 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 to `false`. ## 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: ```tsx // 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 from `form.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 raw `onClose` on 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 `dirtyFields` from the same subscription instead of hand-comparing `defaultValues.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. - `setValue` outside user input needs explicit flags: `setValue('x', v, { shouldDirty: true, shouldValidate: true })` — otherwise the change is invisible to `isDirty` and 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 using `values:`), `onError` → `toast.error`; pass the mutation's `isPending` to the button's `loading` prop. Default validation `mode: '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 |
GitHubで見る
この SKILL.md は非常に大きいため、SkillsMP では最初のセクションだけを表示しています。 GitHubで見る