React Hook Form 入門 Part 3 — Controller と useFieldArray を実務で使いこなす
読了時間の目安: 約15分 · React Hook Form v7 対応
シリーズ構成
- Part 1: RHF の仕組みを理解してから使う
- Part 2: バリデーション設計を実務レベルに引き上げる
- Part 3: Controller と useFieldArray(本記事)
Part 1 では RHF がなぜ速いのかを整理し、Part 2 ではバリデーション設計を掘り下げました。今回は多くの人が最初につまずく問題を解決します。
「なぜ
registerだけでは動かないのか?」
特に以下のようなケースで起きます。
- UI ライブラリ(MUI / Ant Design / Chakra UI)の入力コンポーネント
- DatePicker / Select / React Select
- 動的に増減するフォーム(電話番号、住所、メンバー追加)
ここで登場するのが Controller と useFieldArray です。この 2 つを理解すると、React Hook Form を実務でかなり自由に扱えるようになります。
なぜ register では動かないのか
Part 1 で説明した通り、RHF は uncontrolled component を前提に設計されています。
<input {...register("name")} />
これは普通の HTML input なので問題ありません。ref を渡せて、値を DOM が持ち、RHF が直接参照できるからです。
しかし UI ライブラリのコンポーネントは多くの場合 controlled component として作られています。
<DatePicker />
<Select />
<ReactSelect />
これらは value と onChange で管理されていて、ref ベースではありません。そのため {...register()} では正しく動きません。ここで使うのが Controller です。
Controller とは何か
Controller は controlled component を RHF に接続するための橋 です。
UI ライブラリのコンポーネント
↓
Controller
↓
React Hook Form
register が使えない場所では、ほぼ Controller を使います。
基本的な使い方
import { Controller, useForm } from "react-hook-form";
const { control, handleSubmit } = useForm();
<Controller
name="username"
control={control}
render={({ field }) => (
<Input {...field} />
)}
/>
field の正体
render={({ field }) => ...} の field には以下が入っています。
{
value,
onChange,
onBlur,
name,
ref
}
つまり <Input {...field} /> は実質的に以下と同じです。
<Input
value={value}
onChange={onChange}
onBlur={onBlur}
/>
Controller はこの接続を自動でやってくれます。
実務例
React Select
<Controller
name="role"
control={control}
render={({ field }) => (
<Select
{...field}
options={[
{ label: "Admin", value: "admin" },
{ label: "User", value: "user" },
]}
/>
)}
/>
重要: 普通の input の onChange は event.target.value を返しますが、React Select の onChange は selectedOption オブジェクト全体を返します。RHF に value だけを保存したい場合は明示的に変換が必要です。
<Controller
name="role"
control={control}
render={({ field }) => (
<Select
value={field.value}
onChange={(option) => field.onChange(option?.value)}
options={[
{ label: "Admin", value: "admin" },
{ label: "User", value: "user" },
]}
/>
)}
/>
field.onChange(option?.value) とすることで、RHF には "admin" や "user" という文字列だけが保存されます。
DatePicker
<Controller
name="birthday"
control={control}
render={({ field }) => (
<DatePicker
selected={field.value}
onChange={field.onChange}
/>
)}
/>
エラー表示
Controller を使う場合でも、エラーは formState.errors から取得できます。
const {
control,
handleSubmit,
formState: { errors },
} = useForm({
defaultValues: { role: "", birthday: null },
});
<Controller
name="role"
control={control}
rules={{ required: "役職を選択してください" }}
render={({ field }) => (
<>
<Select
value={field.value}
onChange={(option) => field.onChange(option?.value)}
options={[
{ label: "Admin", value: "admin" },
{ label: "User", value: "user" },
]}
/>
{errors.role && (
<p style={{ color: "red" }}>{errors.role.message}</p>
)}
</>
)}
/>
よくあるミス
defaultValues を忘れる
Controller だけだと warning が出ることがあります。必ず useForm 側で設定しましょう。
useForm({
defaultValues: {
birthday: null,
role: "",
},
})
register と field を混在させる
// NG — 二重管理になる
<Input {...register("email")} {...field} />
どちらか片方に統一してください。
useFieldArray
動的フォームを管理する Hook です。
- 電話番号を複数追加
- メンバーを複数追加
- 複数住所・商品一覧・SNS リンク追加
実務で非常によく使います。
基本形
const { control, register } = useForm({
defaultValues: {
phones: [{ number: "" }],
},
});
const { fields, append, remove } = useFieldArray({
control,
name: "phones",
});
実装例
{fields.map((field, index) => (
<div key={field.id}>
<input
{...register(`phones.${index}.number`)}
placeholder="電話番号"
/>
<button type="button" onClick={() => remove(index)}>
削除
</button>
</div>
))}
<button type="button" onClick={() => append({ number: "" })}>
追加
</button>
なぜ field.id を使うのか
// ✅ 正しい
key={field.id}
// ❌ 危険
key={index}
key={index} を使うと、削除時に React が誤認識して値がズレることがあります。必ず field.id を使ってください。
エラー表示(useFieldArray)
const {
control,
register,
formState: { errors },
} = useForm({
defaultValues: {
phones: [{ number: "" }],
},
});
{fields.map((field, index) => (
<div key={field.id}>
<input
{...register(`phones.${index}.number`, {
required: "電話番号を入力してください",
pattern: {
value: /^[0-9-]+$/,
message: "数字とハイフンのみ入力できます",
},
})}
placeholder="電話番号"
/>
{errors.phones?.[index]?.number && (
<p style={{ color: "red" }}>
{errors.phones[index].number.message}
</p>
)}
<button type="button" onClick={() => remove(index)}>
削除
</button>
</div>
))}
Controller + useFieldArray の組み合わせ
フィールドが動的で、かつ UI ライブラリを使う場合はこの組み合わせになります。
const { control, handleSubmit, formState: { errors } } = useForm({
defaultValues: {
members: [{ name: "", role: "" }],
},
});
const { fields, append, remove } = useFieldArray({
control,
name: "members",
});
{fields.map((field, index) => (
<div key={field.id}>
{/* 通常の input は register で OK */}
<input
{...register(`members.${index}.name`, {
required: "名前を入力してください",
})}
placeholder="名前"
/>
{errors.members?.[index]?.name && (
<p style={{ color: "red" }}>
{errors.members[index].name.message}
</p>
)}
{/* UI ライブラリのコンポーネントは Controller を使う */}
<Controller
name={`members.${index}.role`}
control={control}
rules={{ required: "役職を選択してください" }}
render={({ field }) => (
<>
<Select
value={field.value}
onChange={(option) => field.onChange(option?.value)}
options={[
{ label: "Admin", value: "admin" },
{ label: "User", value: "user" },
]}
/>
{errors.members?.[index]?.role && (
<p style={{ color: "red" }}>
{errors.members[index].role.message}
</p>
)}
</>
)}
/>
<button type="button" onClick={() => remove(index)}>
削除
</button>
</div>
))}
<button
type="button"
onClick={() => append({ name: "", role: "" })}
>
+ メンバー追加
</button>
パフォーマンスの注意点
fields が多い場合、不要な再レンダリングを避けるために以下を検討してください。
-
React.memoでフィールドコンポーネントを分割する -
useWatchで監視範囲を絞る - 各行を独立したコンポーネントに切り出す
大規模フォームではここが差になります。
まとめ
| Hook / コンポーネント | 用途 |
|---|---|
register |
普通の HTML input |
Controller |
UI ライブラリのコンポーネント |
useFieldArray |
動的に増減するフォーム |
この 3 つで React Hook Form の 80% をカバーできます。特に Controller と useFieldArray の組み合わせを押さえると、実務レベルのフォームはほぼ対応できます。