react-hook-form

작성자: supabase

모노레포 어디서든 올바른 React Hook Form 사용법 — 데이터 흐름, 구독, 리셋, 더티 상태, 숫자 입력, 제어 입력 규칙. 이것을 로드하세요…

npx skills add https://github.com/supabase/supabase --skill react-hook-form

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:

APISubscribesRe-rendersUse for
useWatch({ control, name })yesonly the calling componentreactive value reads, anywhere
useFormState({ control })yesonly the calling componentisDirty/errors/etc. outside the form owner
formState (destructured)yesthe useForm ownerform state in the owner component only
form.watch(name)yesthe entire form treeavoid — lint-flagged, see below
getValues()noneverevent handlers and onSubmit only
subscribe()callbacknoneside 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.
// ❌ 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 → FormItemLayoutFormControl → 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 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. (Good examples: components/interfaces/Settings/Database/ConnectionLogging.tsx, components/interfaces/Storage/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 !== watchednull !== 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:

// 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: onSuccesstoast.success

  • reset(values) (or query invalidation when using values:), onErrortoast.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)

RuleLevelMeaning
react-hook-form/destructuring-formstateerrordestructure formState, never hold the object
react-hook-form/no-access-controlerrordon't reach into control internals
react-hook-form/no-nested-object-setvalueerrorsetValue('a.b', v), not setValue('a', {b:v})
react-hook-form/no-use-watchwarn (ratcheted)use useWatch, not watch

supabase의 다른 스킬

studio-e2e-tests
supabase
Supabase Studio용 Playwright E2E 테스트를 작성하고 실행합니다. 요청 시 사용하세요.
pm-the-docs
supabase
Docs-PM 의사결정 지원 도구로, "Write the docs" 작성 프로세스에서 Frame 및 Shape 단계 중 대상 독자, 단계, 교차 범위 결정을 지원합니다…
studio-best-practices
supabase
Supabase Studio를 위한 React 및 TypeScript 모범 사례. Studio 컴포넌트 작성 또는 검토 시 사용 — boolean 명명, 컴포넌트 구조 등을 다룹니다.
docs-content
supabase
Supabase 콘텐츠를 apps/docs 어디에서든 작성, 편집, 구성, 검토하세요 — 가이드, 설명 자료, 튜토리얼, 문제 해결 항목, 참조 문서 등…
studio-mock-api-tests
supabase
Supabase Studio의 컴포넌트 테스트로, MSW를 사용하여 네트워크 계층에서 API 요청을 모킹합니다. React…를 실행하는 컴포넌트 테스트를 작성하거나 검토할 때 사용하세요.
studio-ui-patterns
supabase
Supabase Studio용 디자인 시스템 UI 패턴. 페이지, 양식, 테이블, 차트, 빈 상태, 네비게이션, 카드, 알림 또는 사이드…를 구축하거나 업데이트할 때 사용하세요.
studio-queries
supabase
Supabase Studio에서 데이터 페칭을 위한 React Query 컨벤션. apps/studio/data/에서 쿼리 훅, 뮤테이션 훅, 또는 쿼리 키를 작성하거나 리뷰할 때 사용하세요—…
studio-testing
supabase
Supabase Studio용 테스트 전략. 테스트 작성 시, 변경 사항에 테스트가 필요한지와 어떤 유형이 필요한지 결정할 때, 컴포넌트에서 로직을 추출할 때 사용합니다…