React Hook Form のパフォーマンス最適化実践ガイド
大規模フォームの再レンダリングを抑制し、UX を改善する実装パターンを解説。制御モード・バリデーション戦略・動的フィールドの最適化まで、受託開発で即使える実務ガイド。

React Hook Form のパフォーマンス最適化実践ガイド
受託開発の現場では、複雑なフォームの実装が避けられません。100 個以上の入力項目を持つ管理画面、条件によって項目が動的に変わる申込フォーム、リアルタイムバリデーションが必要な入力画面——こうしたフォームで React Hook Form を使っていても、再レンダリングが多発し、入力が遅延するケースがよくあります。
本記事では、React Hook Form のパフォーマンスを最大化する実装パターンを、実務で即使える形で解説します。
1. パフォーマンス問題が起きる典型的なケース
1.1 大規模フォームでの再レンダリング
よくある症状
- 1 つの項目を入力するたびに全フィールドが再レンダリング
- 入力に 100ms 以上の遅延が発生
- バリデーションメッセージの表示が遅い
原因
// ❌ 悪い例:全フォーム状態を watch している
function LargeForm() {
const { register, watch } = useForm();
const values = watch(); // 全フィールドの変更で再レンダリング
return (
<form>
{fields.map((field) => (
<Input key={field.name} {...register(field.name)} />
))}
<pre>{JSON.stringify(values, null, 2)}</pre>
</form>
);
}
1.2 動的フィールドでの問題
// ❌ 悪い例:useFieldArray の再レンダリングが全体に波及
function DynamicFieldsForm() {
const { control, watch } = useForm();
const { fields, append } = useFieldArray({ control, name: "items" });
const allValues = watch(); // 配列の変更で全体が再レンダリング
return (
<div>
{fields.map((field, index) => (
<div key={field.id}>
<Input {...register(`items.${index}.name`)} />
</div>
))}
</div>
);
}
1.3 リアルタイムバリデーションの負荷
// ❌ 悪い例:毎回 API を叩く重いバリデーション
const { register } = useForm({
mode: "onChange", // 全入力で発火
resolver: async (data) => {
// 重い API バリデーション
const result = await fetch("/api/validate", {
method: "POST",
body: JSON.stringify(data),
});
return result.json();
},
});
2. 基本戦略:必要な箇所だけ監視・更新する
2.1 watch の使い分け
| メソッド | 再レンダリング | 用途 |
|---------|--------------|------|
| watch() | 全フィールド変更で発火 | デバッグ・プレビュー全体表示 |
| watch("fieldName") | 特定フィールドのみ | 条件分岐・連動表示 |
| useWatch({ control, name: "fieldName" }) | 特定フィールドのみ | コンポーネント分割時の監視 |
| getValues() | 再レンダリングなし | submit 時の値取得 |
// ✅ 良い例:必要な項目だけ watch
function OptimizedForm() {
const { register, control } = useForm();
const email = useWatch({ control, name: "email" }); // email のみ監視
return (
<form>
<Input {...register("name")} />
<Input {...register("email")} />
{email && <p>確認メールを {email} に送信します</p>}
</form>
);
}
2.2 コンポーネント分割による再レンダリング抑制
// ✅ 良い例:フィールドごとにコンポーネント分割
function EmailField({ control }: { control: Control<FormData> }) {
const email = useWatch({ control, name: "email" });
return (
<div>
<Input {...register("email")} />
{email && <span>確認メールを送信</span>}
</div>
);
}
function OptimizedForm() {
const { control, register } = useForm<FormData>();
return (
<form>
<Input {...register("name")} /> {/* name 変更時は再レンダリングされない */}
<EmailField control={control} /> {/* email 変更時のみ再レンダリング */}
</form>
);
}
3. バリデーション戦略の最適化
3.1 バリデーションモードの選定
| モード | 発火タイミング | 用途 |
|--------|--------------|------|
| onSubmit | submit 時のみ | デフォルト・最も軽量 |
| onBlur | フォーカス離脱時 | ユーザー体験とパフォーマンスのバランス |
| onChange | 入力のたび | リアルタイム必須(パスワード強度など) |
| onTouched | 初回 blur 後は onChange | 段階的なフィードバック |
| all | 全タイミング | 非推奨(重い) |
// ✅ 良い例:フィールドごとにモードを分ける
const { register } = useForm({
mode: "onBlur", // デフォルトは blur 時
});
<Input
{...register("email", {
required: "メールアドレスは必須です",
})}
/>;
<Input
{...register("password", {
onChange: (e) => {
// パスワードだけリアルタイムチェック
checkPasswordStrength(e.target.value);
},
})}
/>;
3.2 非同期バリデーションのデバウンス
import { useDebouncedCallback } from "use-debounce";
// ✅ 良い例:API バリデーションをデバウンス
function AsyncValidationField() {
const { register, setError, clearErrors } = useForm();
const validateUsername = useDebouncedCallback(async (value: string) => {
if (!value) return;
const available = await fetch(`/api/check-username?name=${value}`).then(
(r) => r.json()
);
if (!available) {
setError("username", {
type: "manual",
message: "このユーザー名は既に使用されています",
});
} else {
clearErrors("username");
}
}, 500); // 500ms のデバウンス
return (
<Input
{...register("username", {
required: "ユーザー名は必須です",
onChange: (e) => validateUsername(e.target.value),
})}
/>
);
}
3.3 Zod と組み合わせた段階的バリデーション
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
// ✅ 良い例:軽いバリデーションと重いバリデーションを分離
const baseSchema = z.object({
email: z.string().email("メールアドレスの形式が不正です"),
password: z.string().min(8, "8文字以上で入力してください"),
});
const asyncSchema = baseSchema.refine(
async (data) => {
// 重い API チェックは refine で分離
const available = await fetch("/api/check-email", {
method: "POST",
body: JSON.stringify({ email: data.email }),
}).then((r) => r.json());
return available;
},
{ message: "このメールアドレスは既に登録されています", path: ["email"] }
);
const { register, handleSubmit } = useForm({
mode: "onBlur", // blur 時は軽い baseSchema のみ
resolver: zodResolver(baseSchema),
});
const onSubmit = async (data: FormData) => {
// submit 時だけ重い asyncSchema を実行
const result = await asyncSchema.safeParseAsync(data);
if (!result.success) {
// エラーハンドリング
}
};
4. useFieldArray の最適化
4.1 key の適切な管理
// ✅ 良い例:field.id を key に使う
function DynamicForm() {
const { control, register } = useForm();
const { fields, append, remove } = useFieldArray({
control,
name: "items",
});
return (
<div>
{fields.map((field, index) => (
<div key={field.id}> {/* ⚠️ index ではなく field.id */}
<Input {...register(`items.${index}.name`)} />
<button type="button" onClick={() => remove(index)}>
削除
</button>
</div>
))}
<button type="button" onClick={() => append({ name: "" })}>
追加
</button>
</div>
);
}
4.2 部分的な更新
// ✅ 良い例:特定の要素だけ更新
function PartialUpdateForm() {
const { control, register } = useForm();
const { fields, update } = useFieldArray({ control, name: "items" });
const handleToggle = (index: number) => {
const item = fields[index];
update(index, { ...item, checked: !item.checked }); // 該当インデックスのみ更新
};
return (
<div>
{fields.map((field, index) => (
<div key={field.id}>
<Input {...register(`items.${index}.name`)} />
<button type="button" onClick={() => handleToggle(index)}>
{field.checked ? "✓" : ""}
</button>
</div>
))}
</div>
);
}
5. 制御コンポーネントと非制御コンポーネントの使い分け
5.1 基本方針
| 方式 | メリット | デメリット | 用途 |
|------|---------|----------|------|
| 非制御(register) | 再レンダリングなし・高速 | リアルタイム表示に不向き | 通常の入力フィールド |
| 制御(Controller) | 細かい制御が可能 | 再レンダリング発生 | カスタム UI ライブラリ(MUI、Ant Design) |
import { Controller } from "react-hook-form";
import { TextField } from "@mui/material";
// ✅ 良い例:必要な箇所だけ Controller を使う
function MixedForm() {
const { register, control } = useForm();
return (
<form>
{/* 非制御:軽量 */}
<input {...register("name")} />
{/* 制御:MUI を使う箇所のみ */}
<Controller
name="email"
control={control}
render={({ field }) => <TextField {...field} label="メール" />}
/>
</form>
);
}
5.2 shouldUnregister によるメモリ管理
// ✅ 良い例:条件付きフィールドの登録解除
function ConditionalForm() {
const { register, watch } = useForm({
shouldUnregister: true, // 非表示フィールドを自動で登録解除
});
const showAddress = watch("needsShipping");
return (
<form>
<input type="checkbox" {...register("needsShipping")} />
{showAddress && (
<>
<input {...register("address")} />
<input {...register("postalCode")} />
</>
)}
</form>
);
}
6. 実務でよく使うパフォーマンスチェックリスト
必須項目
- [ ]
watch()を全フィールド監視で使っていないか - [ ] バリデーションモードは
onSubmitまたはonBlurか - [ ] 非同期バリデーションにデバウンスを設定しているか
- [ ] useFieldArray で
field.idを key に使っているか - [ ] 制御コンポーネントは必要最小限か
推奨項目
- [ ] 大規模フォームはコンポーネント分割しているか
- [ ]
useWatchで特定フィールドだけ監視しているか - [ ]
shouldUnregister: trueで不要な状態を削除しているか - [ ] バリデーションエラーを
setErrorで手動設定しているか(API エラー時) - [ ]
React.memoでフィールドコンポーネントをメモ化しているか
パフォーマンス計測
import { useEffect } from "react";
// ✅ 良い例:再レンダリング回数を計測
function PerformanceMonitor({ name }: { name: string }) {
const renderCount = useRef(0);
useEffect(() => {
renderCount.current += 1;
console.log(`${name} rendered ${renderCount.current} times`);
});
return null;
}
function MonitoredForm() {
return (
<form>
<PerformanceMonitor name="Form" />
<Input {...register("name")} />
</form>
);
}
7. トラブルシューティング
7.1 入力が遅延する
チェックポイント
watch()で全フィールド監視していないかmode: "onChange"で重いバリデーションを実行していないか- フィールド数が 50 個以上あるか(コンポーネント分割を検討)
対処法
// ✅ デバウンスを追加
import { useDebouncedCallback } from "use-debounce";
const debouncedSetValue = useDebouncedCallback((name, value) => {
setValue(name, value);
}, 300);
7.2 useFieldArray の更新が反映されない
原因
keyにindexを使っているdefaultValuesが未設定
対処法
// ✅ 正しい実装
const { fields } = useFieldArray({
control,
name: "items",
keyName: "fieldId", // デフォルトは "id"
});
{fields.map((field, index) => (
<div key={field.fieldId}> {/* field.id ではなく field.fieldId */}
<Input {...register(`items.${index}.name`)} />
</div>
))}
7.3 バリデーションが発火しない
原因
modeがonSubmitのままregisterのrequiredに空文字を指定している
対処法
// ❌ 空文字は false と判定される
<Input {...register("name", { required: "" })} />
// ✅ 明示的なメッセージ
<Input {...register("name", { required: "名前は必須です" })} />
まとめ
React Hook Form のパフォーマンス最適化は、必要な箇所だけ監視・更新するという原則に尽きます。
今日から実践できるポイント
watch()の全フィールド監視をuseWatchに置き換える- バリデーションモードを
onBlurに変更する - 非同期バリデーションにデバウンスを追加する
- useFieldArray で
field.idを key に使う - 大規模フォームはコンポーネント分割する
これらを実装するだけで、入力遅延が解消され、ユーザー体験が大きく改善します。
複雑なフォーム実装でお困りですか?
Yureate では、React Hook Form を使った高パフォーマンスなフォーム設計・実装を支援しています。大規模管理画面や動的フォームの開発実績が豊富です。お気軽にお問い合わせください。
