mcpField
R
react-hook-form

react-hook-form

github.com/supabase開発114.6k73 回インストール今日更新
リポジトリ

react-hook-form について

既存コードベースで React Hook Form のフォームを開発・保守するためのスキルです。購読範囲を絞り、制御値、初期値、検証、リセットの一貫性を保つ方法を扱います。

主な機能

  1. 01useWatch と useFormState による読み取り範囲の限定
  2. 02完全な初期値とサーバー由来の values
  3. 03制御入力と null 値の処理
  4. 04数値入力の安全な解析
  5. 05dirty 状態とリセットの指針

活用シーン

  1. 01フォーム全体を再描画せずにリアクティブなフィールドを追加
  2. 02API のデータをフォームに反映し編集内容を保持
  3. 03null 許容のテキストや数値フィールドを安全に処理
  4. 04lint が警告する watch の書き方を避けてフォームを更新

インストール

コマンドをコピーしてターミナルで実行します。

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

インストール後、Claude Code で/react-hook-formを入力して読み込まれたか確認します。

詳細

React Hook Form

拡張しても正しさを保てるフォームの書き方です。既存のコードベースは安全な参考例ではありません。props 経由で渡されたフォームオブジェクトに対する form.watch()、サブスクリプション専用の監視、ガードなしの valueAsNumber、?? undefined による制御値は、いずれも古いコードでよく見られますが、すべて誤りです。隣のファイルではなく、このスキルに従ってください。

ポリシー — 触った箇所は修正する。 新しいコードでは、これらのルールに従う必要があります。既存のフォームコードを変更するときは、編集するフィールド、フック、コンポーネントをルールに合わせて更新してください(例:触ったコンポーネントが form.watch を呼び出している場合は、useWatch に置き換えます)。手を加えないコードはそのままにしますが、気づいたものの修正しなかったアンチパターンはユーザーに伝えてください。新たな違反は決して追加しないでください。react-hook-form/no-use-watch は Studio CI で警告数が増えないよう設定されているため、警告数が増えるとビルドに失敗します。

基本的な考え方:サブスクリプションによって再レンダリングする対象が決まる

RHF は本質的に非制御です。値は ref に保持され、サブスクリプションが指定されない限り再レンダリングは発生しません。値を読み取る API はすべて、サブスクリプションに関する選択です。

APIサブスクライブ再レンダリングする対象用途
useWatch({ control, name })はい呼び出し元のコンポーネントのみリアクティブな値の読み取り(場所を問わず)
useFormState({ control })はい呼び出し元のコンポーネントのみフォームの所有者以外での isDirty/errors など
formState(分割代入)はいuseForm の所有者所有者コンポーネント内でのみフォーム状態を参照
form.watch(name)はいフォームツリー全体避けてください。下記のとおり Lint の警告対象です
getValues()いいえなしイベントハンドラーと onSubmit のみ
subscribe()コールバックなしレンダリング外での副作用

これまでに発生した不具合の大半は、次の2つの事実で説明できます。

  1. form.watch() と form.formState のサブスクリプションは、呼び出すコンポーネントにかかわらず useForm の所有者に設定されます。 子コンポーネントが props 経由で form.watch('x') を読み取って現在動作しているのは、値が変わるたびにツリー全体が再レンダリングされるからにすぎません。所有者と子の間に React.memo が追加されると、すぐに値が古いままになります。それまでは、キー入力のたびにすべての兄弟コンポーネントまで再レンダリングされます。引数なしの form.watch() は watchAll を設定し、フォームが存続する間、フィールドが変更されるたびにツリー全体を再レンダリングします。
  2. formState は Proxy です。プロパティを読み取ることでサブスクリプションが有効になります。分割代入してください(const { isDirty } = form.formState)。オブジェクトを渡し回したり、条件付きで読み取ったりしてはいけません(a && formState.isValid ではサブスクライブされない場合があります)。react-hook-form/destructuring-formstate(エラー)によって強制されています。
場所ごとの値の読み取り
  • useForm を所有するコンポーネント内: formState を分割代入します。ここでも form.watch より useWatch を優先してください(no-use-watch ルールはすべての watch を検出し、JSX を後で切り出した場合も useWatch なら再レンダーの範囲を限定できます)。
  • 子コンポーネントまたはカスタムフック内: form 全体ではなく control を受け取り、useWatch({ control, name })/useFormState({ control }) を使います。<Form {...form}>(これは FormProvider です)の内部では、useFormContext() と useWatch({ name }) の組み合わせも使え、プロパティのバケツリレーを完全に避けられます。
  • 戻り値を使用してください。 ウォッチのサブスクリプションの副作用だけを目的に呼び出し、その後 getValues() で読み取ることは避けてください。ウォッチするリストと読み取るリストが一致しなくなります(実際にすでに起きており、フィールドのリアクティビティが気付かないうちに失われました)。レンダリングする値は、サブスクライブした値そのものでなければなりません。
  • レンダーごとに、各値の読み取り経路は1つにしてください。 ある行で useWatch('x') を使い、数行後に getValues('x') を使うと、単一のレンダー内で両者が食い違う可能性があります。
  • ウォッチする名前を指定してください。 useWatch({ control }) を name なしで使うと、すべてのフィールドの入力ごとに再レンダーされます。使用する名前を指定してサブスクライブしてください。
  • watch(callback) は非推奨です。レンダーを発生させないリスナーには subscribe() を使い、必ず 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 })
  ...
}

標準的なフォーム

zod スキーマ → z.infer 型 → zodResolver と 完全な defaultValues を指定した useForm → <Form {...form}> → フィールドごとの FormField レンダープロップ → FormItemLayout → FormControl → ui のプリミティブ。レイアウト/コンテナーの選択肢(Card と Sheet、layout= のバリエーション)については、studio-ui-patterns スキルと apps/design-system/registry/default/example/ のデモ(form-patterns-pagelayout.tsx、form-patterns-sidepanel.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>

スキーマ、type、静的な defaultValues、フォームの id はモジュールレベルでコンポーネントの外に定義してください。レンダーごとに再構築するのは無駄な処理であり、参照も不安定になります。RHF が defaultValues を読み取るのは初回レンダー時だけですが、これらのオブジェクトと比較するものは、毎回新しい同一性を持つオブジェクトを参照することになります。実行時データに本当に依存する場合は、スキーマを useMemo で構築し、モジュールレベルへの移動ではなく、次のセクションの values オプションを通じてサーバー駆動のデフォルト値を渡してください。

<form> の外側にある送信ボタン(シート/ダイアログのフッター)には、ボタンの form={FORM_ID} に同じモジュールレベルの FORM_ID を指定します。モジュールレベルの id を安全に使えるのはシングルトンのフォームだけです。コンポーネントが同時に複数回マウントされる可能性がある場合、id が重複すると、外部のボタンは最初に一致したフォームを送信してしまいます。そのため、useId() でインスタンスごとの id を生成し、<form> とそのボタンで共有してください。

defaultValues、サーバーデータ、リセット

  • 完全な defaultValues オブジェクトを用意してください。すべてのフィールドを含め、undefined は使わないでください。 isDirty、dirtyFields、およびキャンセル時のリセットは、すべてこの値と比較します。デフォルト値が欠けていたり undefined だったりすると、これらすべてが正しく機能しません。また、undefined によって React が 入力を非制御として扱うようになります(下記参照)。
  • API からフォームに値を設定する場合は、独自の エフェクトではなく values オプションを使ってください。 values はクエリの解決に反応し、フォームを自動でリセットします。 まだ読み込まれていない可能性のあるクエリから defaultValues を計算すると、マウント時にキャッシュにあった値が 固定されてしまいます。バックグラウンドの再フェッチでユーザーが編集中の内容を上書きしないようにするには、 resetOptions: { keepDirtyValues: true } を追加してください。keepDirtyValues は formState.dirtyFields にある値を保持します。入力による編集と setValue(…, { shouldDirty: true }) はサブスクリプションの有無にかかわらずこの値に反映されますが、useFieldArray の操作 (append/remove/move)は、dirtyFields または isDirty がサブスクライブされている場合にのみ フィールドを dirty としてマークします。そのため、keepDirtyValues とフィールド配列を組み合わせるフォームでは、 所有側でいずれかを読み取る必要があります。そうしないと、次の再フェッチで配列の編集内容が破棄されます。(良い例: components/interfaces/Settings/Database/ConnectionLogging.tsx、 components/interfaces/Storage/FilesBuckets/EditBucketModal.tsx。)
  • ミューテーションが成功したら、onSuccess でフォームの基準値を更新してください。 保存済みの状態が新しい基準値となり、 isDirty は false に戻り、キャンセルすると保存済みの値に戻ります。実際にサーバーへ保存された値を使ってください。フォームで values を使っていて、ミューテーションによってクエリが無効化される場合は、再フェッチによって自動的に処理されます。 ミューテーションが更新されたリソースを返す場合は、reset(response) を使ってください。 reset(submittedValues) は、送信した値がそのまま保存される API に対する代替手段です。サーバーが値を正規化したり補完したりする場合、 実際には保存されていないデータを基準値にしてしまいます。引数なしの reset() は 以前の デフォルト値に戻すため、保存後には不適切です。
  • キャンセルボタンは form.reset() を呼び出します。値が定義済みの制御値を通じて往復できるフィールドだけが視覚的に復元されます。 そのため、以下の null に関するルールが重要です。

制御入力:value を undefined に変化させない

React は、各レンダーで value が定義されているかどうかによって、制御入力か非制御入力かを判断します。値が undefined になり得るフィールド(またはリセット時に undefined になるフィールド)は、モードが切り替わり、 コンソール警告が表示されます。さらに悪いことに、React が DOM の値を破棄するため、reset() で表示中のテキストが 消えなくなります。value={field.value ?? undefined} は修正ではなく、バグです。

  • テキストフィールド:デフォルトは '' にし、null/undefined は使わないでください。
  • フォームの境界で API からの null を正規化してください(デフォルト値を作成するときに growthPercent ?? '' を使います)。送信時には元に戻してください('' → null)。 値のように見える placeholder で null のデフォルト値をごまかさないでください。ユーザーには「50」と表示されても、 フォーム内の値は null のままです。そのため、下流の比較 (defaultValues.growthPercent !== watched → null !== 50)は永続的な見せかけの変更を報告し、 キャンセルしてもフィールドがひそかにリセットされません。
  • セレクト/ラジオ:デフォルトは '' または実際のオプション値にします。チェックボックス/スイッチは false にします。

数値入力

推奨パターンでは、入力を制御された状態に保つため、'' を「空」のセンチネルとして維持し、検証時に Zod で型変換します(上記の maxConnections を参照してください)。 z.union([z.literal(''), z.coerce.number()...]).refine((v) => v !== '', '…') と、通常の <Input {...field} type="number" /> を使用します。

代わりに e.target.valueAsNumber(または valueAsNumber: true)を通して onChange を設定すると、入力が空または入力途中の場合に NaN が生成され、フォームの状態に入り、以降のすべての計算、価格プレビュー、value 属性に伝播します。フィールドのスキーマで宣言されている同じ空のセンチネルでガードしてください。上記の '' を使うユニオンスキーマの場合: field.onChange(Number.isNaN(e.target.valueAsNumber) ? '' : e.target.valueAsNumber)。 NaN をフォームの状態に入れないでください。

API の nullable フィールド(null =「未設定」。例:プラットフォームのデフォルトが適用される)は、フォーム内のセンチネルを変えるものではありません。フォーム内では '' を維持し、境界で変換してください。

// 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 })

フォームの状態に null が入る場合(既存のフォームの中にはそうなっているものもあります)、入力と型変換のどちらにも渡さないでください。value={field.value ?? ''} で表示し、値を z.coerce.number() に渡さないようにします。Number(null) は 0 となるため、nullable フィールドを型変換するユニオンに渡すと、空の値が暗黙的に 0 として検証されてしまいます。いずれの場合も、フィールドごとにセンチネルは 1 つだけにし、デフォルト値、スキーマ、onChange、表示、送信時のマッピングで一貫して使用してください。

Dirty 状態と変更検出

  • 保存は isDirty のときだけ有効にし、キャンセルは変更がある場合にのみ表示します。所有者コンポーネントでは form.formState から分割代入し、それ以外では useFormState({ control }) を使用してください。
  • フォームが Sheet または Dialog 内にある場合は、変更がある状態での閉じる操作も設定してください。 useConfirmOnClose と DiscardChangesConfirmationDialog を使います。キャンセル、Escape、背景のクリックはガードを通し、送信が成功した場合は保存後の確認ダイアログを表示しないよう、生の onClose を呼び出してください。詳細: apps/design-system/content/docs/ui-patterns/modality.mdx(Dirty form dismissal)および studio-ui-patterns スキルの Sheets セクション。
  • 変更されたフィールドを表示する場合(レビュー/要約ダイアログなど)は、defaultValues.x !== watchedX を手動で比較せず、同じサブスクリプションから dirtyFields を読み取ってください。RHF はこの比較を正しく処理します。手書きの比較では null とプレースホルダーの不一致で問題が起きるうえ、watch リストとの同期を手動で維持する必要があります。
  • ユーザー入力以外での setValue には、明示的なフラグが必要です。 setValue('x', v, { shouldDirty: true, shouldValidate: true })。そうしないと、変更が isDirty と検証に反映されません。

無効化と条件分岐

フィールドを編集できないようにする必要がある場合(プランのティア、権限、クールダウンなど)は、フィールド自体を無効化してください。編集可能な入力の横に通知を表示しても、何も制限されません。通知とコントロールの両方に同じ条件を設定してください。権限チェックには useAsyncCheckPermissions を使用します。説明が必要な無効状態のボタンには ButtonTooltip を使用してください。

注意:register/useController の disabled: true は、フィールドの値を送信データから除外します。値を送信時にも保持する「表示されているがロックされた」フィールドには、RHF レベルで無効化するのではなく、入力自体の disabled/readOnly プロパティ(FormField とプリミティブのプロパティが行う方法)を使用するか、フォームレベルの disabled オプションを使って非同期処理中にすべてを固定してください。

送信とミューテーション

onSubmit には検証済みの型付きデータが渡されます。それを信頼し、getValues() で再取得しないでください。ミューテーションは Studio の規約に従います。onSuccess → toast.success

  • reset(values)(または values: を使う場合はクエリを無効化)、onError → toast.error。ミューテーションの isPending をボタンの loading プロパティに渡してください。ほとんどのフォームではデフォルトの検証 mode: 'onSubmit' が適切です。別のモードを使う場合は、コピーするのではなく意図的に選んでください。

適用中のLintルール(Studio)

ルールレベル意味
react-hook-form/destructuring-formstateerrorformStateを分割代入し、オブジェクトを保持しない
react-hook-form/no-access-controlerrorcontrolの内部にアクセスしない
react-hook-form/no-nested-object-setvalueerrorsetValue('a', {b:v})ではなく、setValue('a.b', v)を使用する
react-hook-form/no-use-watchwarn (ratcheted)watchではなく、useWatchを使用する

コメント