react-hook-form について
既存コードベースで React Hook Form のフォームを開発・保守するためのスキルです。購読範囲を絞り、制御値、初期値、検証、リセットの一貫性を保つ方法を扱います。
主な機能
- 01useWatch と useFormState による読み取り範囲の限定
- 02完全な初期値とサーバー由来の values
- 03制御入力と null 値の処理
- 04数値入力の安全な解析
- 05dirty 状態とリセットの指針
活用シーン
- 01フォーム全体を再描画せずにリアクティブなフィールドを追加
- 02API のデータをフォームに反映し編集内容を保持
- 03null 許容のテキストや数値フィールドを安全に処理
- 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つの事実で説明できます。
form.watch()とform.formStateのサブスクリプションは、呼び出すコンポーネントにかかわらずuseFormの所有者に設定されます。 子コンポーネントが props 経由でform.watch('x')を読み取って現在動作しているのは、値が変わるたびにツリー全体が再レンダリングされるからにすぎません。所有者と子の間にReact.memoが追加されると、すぐに値が古いままになります。それまでは、キー入力のたびにすべての兄弟コンポーネントまで再レンダリングされます。引数なしのform.watch()はwatchAllを設定し、フォームが存続する間、フィールドが変更されるたびにツリー全体を再レンダリングします。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-formstate | error | formStateを分割代入し、オブジェクトを保持しない |
react-hook-form/no-access-control | error | controlの内部にアクセスしない |
react-hook-form/no-nested-object-setvalue | error | setValue('a', {b:v})ではなく、setValue('a.b', v)を使用する |
react-hook-form/no-use-watch | warn (ratcheted) | watchではなく、useWatchを使用する |