diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 3cabdcbc36c00..8f48264e86e09 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -62,6 +62,7 @@ The skills in `.claude/skills/` are the source of truth for conventions — load - `telemetry-standards` — PostHog events, `packages/common/telemetry-constants.ts` - `dev-toolbar-review` — `packages/dev-tools`, `packages/common/posthog-client.ts`, `packages/common/feature-flags.tsx` - `safe-sql-execution` — any code that builds or executes SQL against user databases +- `react-hook-form` — writing or modifying any form code, anywhere in the monorepo - `vitest` / `vercel-composition-patterns` — generic unit-testing and React composition references ## Studio diff --git a/.claude/skills/react-hook-form/SKILL.md b/.claude/skills/react-hook-form/SKILL.md new file mode 100644 index 0000000000000..29571cb24c742 --- /dev/null +++ b/.claude/skills/react-hook-form/SKILL.md @@ -0,0 +1,278 @@ +--- +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 `