web-forms-react-hook-form について
ReactフォームでReact Hook Formの実装パターンを扱う実用スキルです。フォーム状態や検証に加え、制御コンポーネント、繰り返し行、不要な再レンダリングを抑える購読方法を説明します。
主な機能
- 01型付きフォームと初期値の設定
- 02Controllerによる制御コンポーネント連携
- 03resolverを使ったスキーマ検証
- 04useFieldArrayによる繰り返し行
- 05フォーム状態の購読範囲の限定
活用シーン
- 01検証付きのアクセシブルなフォーム構築
- 02カスタム選択欄や日付ピッカーの連携
- 03追加・削除・並べ替え可能な行の管理
- 04大規模フォームの再レンダリング削減
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ agents-inc/ skills --skill web-forms-react-hook-formインストール後、Claude Code で/web-forms-react-hook-formを入力して読み込まれたか確認します。
同梱ファイル
SKILL.md と一緒にインストールされる参考資料やスクリプト。
metadata.yamlreference.mdexamples/arrays.mdcontrolled-components.mdcore.mdform-options.mdperformance.mdvalidation.mdwizard.md詳細
React Hook Form のパターン
クイックガイド:
registerはネイティブ入力要素をバインドし、非制御のまま保持します。独自の値を保持するコンポーネントにはControllerを使用します。繰り返し行はuseFieldArrayで管理し、field.idをキーにします。バリデーションはregisterのルール、またはresolverを通じたスキーマのいずれかで指定できます。注意すべきなのは再レンダリングです。useWatchとuseFormStateは名前を指定したフィールドを購読しますが、watch()とformStateの広範な分割代入はフォーム全体を購読します。
詳細なリソース:
- examples/core.md —
register、エラー表示、アクセシビリティ属性を備えた型安全なフォーム - examples/controlled-components.md — セレクト、日付ピッカー、チェックボックスグループをラップする
Controller - examples/validation.md —
resolverを通じてスキーマを組み込む方法 - examples/arrays.md — ライブ合計を表示する
useFieldArrayの明細行 - examples/performance.md — 分離された購読、
FormStateSubscribe、useWatchのexactとcompute - examples/form-options.md — 非同期データに対応する
valuesプロパティ、disabled、<Form />コンポーネント - examples/wizard.md — ステップごとに
trigger()を使う複数ステップのフォーム - reference.md — 選択フローチャート、API テーブル、バージョンごとの機能一覧
該当する方法
- ネイティブの
input、select、またはtextarea—registerは ref をフォームに渡し、フィールドは非制御のまま保たれるため、入力しても再レンダリングは発生しません。examples/core.md を参照してください。 - 独自の値を保持するコンポーネント — カスタムセレクト、日付ピッカー、リッチテキストエディターなど、使用可能な ref を公開しないコンポーネントには、
ControllerがvalueとonChangeを渡し、再レンダリングをそのフィールドに限定します。examples/controlled-components.md を参照してください。 registerのルールではなくスキーマで指定するバリデーション —resolverを通じて渡すと、フィールドレベルのrulesは不要になります。examples/validation.md を参照してください。
<critical_requirements>
React Hook Form のコードを書く前に
useForm<FormData>() をジェネリック型とともに呼び出し、すべてのフィールドに defaultValues を設定します。 ジェネリック型によって各フィールドのパスと送信ペイロードの型が決まり、デフォルト値によって最初のレンダーからすべての入力が制御対象としてマウントされます。
useFieldArray の行には field.id をキーとして使います。 これは RHF が行に割り当てる ID で、追加、削除、並べ替えを行っても維持されます。配列のインデックスではそうなりません。
コンポーネントが独自の値を保持する場合は、すぐに Controller を使います。 register にはネイティブ入力まで届く ref が必要です。ref を転送しないコンポーネントはフォームに参加できません。
mode は "onBlur" または "onTouched" に設定します。 デフォルトの "onSubmit" では最初の送信までフィードバックが表示されず、"onChange" ではキー入力のたびに検証が実行されます。
スキーマ検証は resolver 経由で渡し、スキーマは独立したモジュールに配置します。 コンポーネントの外に置いたスキーマは単独でテストでき、複数のフォームで再利用できます。フォーム側に残るのは配線だけです。
</critical_requirements>
自動検出: react-hook-form、useForm、register、handleSubmit、formState、Controller、 useFieldArray、useWatch、useFormContext、useFormState、FormProvider、FormStateSubscribe、SubmitHandler、 resolver、shouldUnregister、valueAsNumber
対象範囲:
- React におけるフォームの状態、送信、検証の配線
- 追加、削除、並べ替えが可能な繰り返しフィールドグループ
- 独自の値を保持し、フォームとの橋渡しが必要なコンポーネント
- 1つのフォームが複数の画面にまたがるマルチステップフロー
- フィールド数の多いフォームでの再レンダーの抑制
別途扱う内容:
- 検証スキーマの作成 —
resolverはスキーマオブジェクトを受け取り、このスキルではフォームへの組み込みのみを扱います。スキーマでルールをどう記述するかは、それを管理する側で決まります。 - 初期値の取得元 — フォームは props または
valuesオプションを通じて初期値を受け取り、自身で取得することはありません。 - 入力のマークアップとスタイル — すべての例では標準的な要素を使っているため、クラス名は自由に設定できます。
フォームの状態は React の状態の外部にあります。入力はフォームに自ら登録し、ref を通じて値を報告するため、キー入力によってフォーム独自のストアが更新されても、フィールドを所有するコンポーネントは再レンダーされません。エラーメッセージ、計算された合計、送信ボタンなど、フォームの状態を読み取るものはすべて、名前付きのスライスを購読します。何も購読しないコンポーネントは再レンダーされません。
これが、広範な読み取りに大きなコストがかかる理由です。レンダー本体での watch() と、6つのプロパティを取り出す formState の分割代入は、どちらもフォーム全体を購読し、ライブラリが実現するよう設計された分離を損ないます。
基本パターン
パターン 1: ジェネリック型を指定した useForm
ジェネリック型、mode、defaultValues を組み合わせることで、型安全性、検証のタイミング、制御対象入力に関する警告が決まります。
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<ContactFormData>({
mode: "onBlur",
defaultValues: { name: "", email: "", message: "" },
});
完全なコード:examples/core.md
パターン 2: 独自の値を保持するコンポーネントに Controller を使う
Controller はフィールド自体をレンダーし、value と onChange を渡します。どちらを使うかの判断基準は、ref がネイティブ入力に転送されるコンポーネントなら register が使え、それ以外では Controller が必要ということです。
<Controller
name="service"
control={control}
rules={{ required: "Service is required" }}
render={({ field, fieldState: { error } }) => (
<>
<Select {...field} options={serviceOptions} />
{error && <span role="alert">{error.message}</span>}
</>
)}
/>
完全なコード:examples/controlled-components.md
パターン 3: 繰り返し行に useFieldArray を使う
fields には行ごとに生成された id が含まれており、これを React のキーとして使います。配列全体に対するルールはフックに指定し、エラーは errors.items.root に格納されます。
const { fields, append, remove } = useFieldArray({ control, name: "items" });
{fields.map((field, index) => (
<div key={field.id}>
<input {...register(`items.${index}.name`)} />
<button type="button" onClick={() => remove(index)}>Remove</button>
</div>
))}
完全なコード:examples/arrays.md
パターン 4: resolver によるスキーマ検証
resolver はフィールドごとの rules に代わるものです。スキーマが有効性を判断し、フィールドのパスに対してエラーを報告し、フォームは配線を担当します。
const { register, handleSubmit } = useForm<FormData>({
resolver: zodResolver(schema),
mode: "onBlur",
defaultValues: { username: "", email: "" },
});
独立したモジュールに置いたスキーマは、レンダーせずにテストでき、複数のフォームで再利用できます。また、パスワードの一致や日付範囲など、フィールドごとの rules では表現できないフィールド間のルールも記述できます。
完全なコード:examples/validation.md
パターン 5:派生値に useWatch を使う
子コンポーネント内の useWatch は名前付きフィールドを購読するため、フィールドが変更されたときに再レンダリングされるのはその子コンポーネントだけです。
function PriceDisplay({ control }: { control: Control<PricingFormData> }) {
const [plan, seats] = useWatch({ control, name: ["plan", "seats"] });
return <div>Total: ${PLAN_PRICES[plan] * seats}</div>;
}
compute オプションを使うと、購読対象をさらに絞り込めます。入力値が変わったときではなく、計算結果が変わったときにコンポーネントが再レンダリングされます。
完全なコード:examples/performance.md のパターン 11 とパターン 12
パターン 6:ネストされたフィールドに useFormContext を使う
FormProvider はフォームメソッドをコンテキストに設定するため、ネストされたセクションや再利用されるセクションは、props のバケツリレーをせずにメソッドを利用できます。ネストが 3 階層ある場合や、セクションが複数回レンダリングされる場合に便利です。それより浅い場合は、control を渡すほうが簡単です。
<FormProvider {...methods}>
<form onSubmit={methods.handleSubmit(onSubmit)}>
<AddressFields prefix="shippingAddress" />
<AddressFields prefix="billingAddress" />
</form>
</FormProvider>
function AddressFields({ prefix }) {
const { register } = useFormContext<CheckoutFormData>();
return <input {...register(`${prefix}.street`)} />;
}
完全なコード:examples/wizard.md
パターン 7:外部データの読み込み
values はリアクティブで、データの変更に合わせてフォームも更新されます。一方、defaultValues はマウント時に一度だけ読み込まれます。values と resetOptions: { keepDirtyValues: true } を組み合わせると、バックグラウンドでの更新によってユーザーの入力内容が失われるのを防げます。
useForm<FormData>({
values: userData,
resetOptions: { keepDirtyValues: true },
});
保存が成功した後に reset(data) を呼び出すと、値とデフォルト値が一緒に置き換わり、isDirty がクリアされます。引数なしで reset() を呼び出すと、元のデフォルト値に戻ります。これがキャンセルボタンの動作です。
完全なコード:examples/form-options.md のパターン 7
パターン 8:エラー表示の分離
name を指定した useFormState は、そのフィールドの状態が変わったときにだけ再レンダリングされます。これにより、エラーメッセージが周囲のフォームを再レンダリングするのを防げます。
function FieldError<T extends FieldValues>({ control, name }: Props<T>) {
const { errors } = useFormState({ control, name });
const error = errors[name];
if (!error) return null;
return <span role="alert">{error.message as string}</span>;
}
完全なコード:examples/performance.md — フックについてはパターン 5、FormStateSubscribe コンポーネント形式についてはパターン 10
<red_flags>
注意事項
実行時に問題が発生します:
useFieldArrayのキーに配列インデックスを使うと、React が誤った行を対応付けます。そのため、途中の項目を削除すると、それより下の値がすべて 1 つ上にずれます。field.idをキーにしてください。- 独自の値を保持するコンポーネントで
registerを使うと、ref が渡されず、フィールドが登録されません。その結果、送信データに値が含まれません。Controllerでラップしてください。 defaultValuesを指定しないと、入力欄は非制御でマウントされ、最初のキー入力で制御に切り替わります。React の警告が出て、SSR ではハイドレーションの不一致として報告されます。""も含め、すべてのフィールドに初期値を設定してください。append、prepend、insertに部分的なオブジェクトを渡すと、存在しないキーの値がundefinedになり、入力欄が非制御として扱われます。完全な項目を渡してください。onSubmit内でエラーがスローされても、handleSubmitは捕捉しません。そのため、拒否が未処理のまま伝播します。コールバック内で捕捉してください。- フィールド配列自体の名前に対して
setValueを使うと、行 ID が値に追従しません。replace()を使ってください。
予想外の動作:
useFormにジェネリック型を指定しないと、フィールドパスと送信データの型がanyになり、register("emial")のような誤りも黙って受け入れられます。- 複数の
formStateプロパティを分割代入すると、それらすべてが購読対象になり、どれかが変わるたびにフォームが再レンダリングされます。コンポーネントが読み取るものだけを取得するか、useFormStateで分離してください。 - レンダー本体で
watch()を使うと、すべてのフィールドを購読します。子コンポーネント内でuseWatchを使うと、名前付きフィールドに絞り込めます。 shouldValidate: trueを指定せずにsetValueを使うと、以前のエラーが表示されたままになります。- 配列全体のエラーは
errors.items.rootに、各項目のエラーはerrors.items[index].fieldに格納されます。 shouldUnregister: trueにすると、アンマウントされたフィールドの値が破棄されます。フィールドを非表示にする機能、特にウィザードでは、false(デフォルト)のままにしてください。useWatchは、購読が設定される前の最初のレンダーではdefaultValueを返します。valuesは変化し続ける外部データ用、defaultValuesは静的な初期値用です。両方を指定した場合、どちらが優先されるかはresetOptionsによって変わります。- ステップごとの検証には
trigger(fieldNames)が必要です。isValidはフォーム全体を反映するため、それを条件にしたウィザードは最初のステップから進めなくなります。
これらの中でよくある問題について、修正前と修正後のコード例をreference.mdに掲載しています。
</red_flags>