React Hook Formのregister/useFieldArrayで再レンダリングを止める
React Hook Formの使い方を仕組みから整理。register/useForm/handleSubmit、非制御で再描画を減らす理由、Controller連携、useFieldArray、formStateの罠まで通しで解説。
入力欄が15個あるフォームを、useStateで素直に作ったことがあります。
1文字打つたびにフォーム全体が再描画される。最初は気づきませんでした。でも項目を増やし、入力欄の中に重い日付ピッカーを置いた瞬間、タイピングがカクつき始めたんです。aと打って、画面にaが出るまで一拍ある。あの違和感。
原因を調べて、React Hook Form(以下RHF)に乗り換えました。同じ15項目フォームが、打鍵ごとの再描画ゼロになった。体感で別物です。
RHFが速いのは魔法じゃなくて、値の持ち方が根本的に違うから。Reactのstateで全部抱えるのをやめて、入力欄そのものに値を預ける。この設計を理解しないまま使うと、「なぜかControllerでラップしないと動かない」「watchを足したら逆に重くなった」と詰まります。
今日は、zodでの検証は姉妹記事に預けて、RHF本体の仕組みとパフォーマンスだけを深掘りします。検索でたどり着いた「useForm registerの使い方」「useFieldArrayの追加削除」に、仕組みごと答えます。
この記事の要点
- RHFが速い核心は非制御コンポーネント。入力値をReactのstateに持たず、DOMの入力欄に保持する。だから打鍵ごとの再描画が起きない。
registerは入力欄をRHFに登録する関数。useFormがフォーム全体の司令塔、handleSubmitが検証を通してから送信を呼ぶゲート。- MUIなどの制御UIライブラリは
Controllerで橋渡しする。非制御では値が伝わらない部品があるため。 - 動的な行(項目を増減するフォーム)は
useFieldArray。mapとuseStateで自作すると再描画とキー管理で必ず破綻する。 formStateはプロキシで購読を最適化している。isDirtyやerrorsを分割代入で読んだ瞬間に追跡が始まる、という癖を知らないと罠になる。- zodスキーマでの検証ルールはreact-hook-form × zodでフォーム検証を二重化する実装手順に分離。この記事はRHFの動かし方に集中。
まず「なぜ速いのか」から:制御と非制御
ここを飛ばすと全部が暗記になるので、最初に押さえます。
Reactのフォームには2つの流派があります。制御コンポーネントと非制御コンポーネントです。
制御は、入力値をReactのstateで握る方式。value={name}とonChange={e => setName(e.target.value)}を書くアレです。1文字打つとsetNameが走り、stateが変わり、コンポーネントが再描画される。値とUIが常に一致するぶん分かりやすい。ただし打鍵ごとに再描画が走るのが弱点です。
非制御は、入力値をDOMの<input>自身に持たせる方式。Reactは値を追いかけず、必要なときだけref経由で読みに行く。打ってもsetStateが走らないので、再描画が起きません。昔ながらのHTMLフォームに近い発想です。
RHFは後者、非制御を土台にしています。だから速い。下の表が両者の違いです。
| 観点 | 制御(useState) | 非制御(React Hook Form) |
|---|---|---|
| 値の置き場所 | Reactのstate | DOMの入力欄(ref経由で読む) |
| 打鍵ごとの再描画 | 起きる | 起きない |
| フォームが大きいとき | 重くなりやすい | 軽いまま |
| 値の取得 | stateを見るだけ | getValuesや送信時にまとめて読む |
| 向く場面 | 入力に応じて即UIを変えたい | 入力数が多い・送信時に値が要る |
つまりRHFは「入力中はReactを働かせず、送信や検証のタイミングでだけ値を集める」という割り切りで速度を出しています。この一点を握っておくと、後で出てくるControllerの存在理由がすっと腑に落ちます。
useFormとregister:司令塔と登録
RHFの中心はuseFormフックです。フォーム1つにつき1回呼び、返ってきた道具一式でフォームを組み立てます。
registerは、その道具のうち「この入力欄をRHFの管理下に入れる」関数です。<input {...register("email")}>と書くと、register("email")がname・onChange・onBlur・refをまとめて返し、それをinputに展開します。refが刺さることで、RHFは値をstateではなくDOMから読めるようになる。これが非制御の正体です。
handleSubmitは送信のゲート。onSubmit={handleSubmit(送信関数)}と渡すと、まず全項目を検証し、エラーがあれば送信関数を呼ばずにerrorsを埋める。検証を通ったときだけ、集めた値を引数にして送信関数を実行します。
最小の形がこれです。3つの道具の役割だけ見てください。
import { useForm } from "react-hook-form";
type FormValues = { email: string };
export function MiniForm() {
// useForm = フォーム全体の司令塔。道具一式を返す
const { register, handleSubmit } = useForm<FormValues>();
// handleSubmit が検証を通したときだけ、この関数が呼ばれる
const onSubmit = (values: FormValues) => {
console.log(values); // { email: "..." }
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
{/* register が name/onChange/onBlur/ref をまとめて配る */}
<input type="email" {...register("email")} />
<button type="submit">送信</button>
</form>
);
}
useStateが1行も出てこないのがポイントです。値はinputが持ち、RHFは送信時にだけrefから拾う。だから打っても再描画が走りません。
コピペで動く:問い合わせフォーム全部入り
ここで動く実物を置きます。検証ルールはRHF組み込みのregisterオプション(required・minLength・pattern)だけで完結させ、zodは持ち込みません。formStateからerrorsとisSubmittingを取り出してエラー表示と二重送信防止に使います。
// src/features/contact/ContactForm.tsx
"use client";
import { useForm } from "react-hook-form";
// フォームが扱う値の形。これがそのまま送信データの契約になる
type ContactValues = {
name: string;
email: string;
message: string;
};
export function ContactForm() {
const {
register,
handleSubmit,
reset,
// formState から欲しいものだけ分割代入で取り出す(後述の購読最適化に効く)
formState: { errors, isSubmitting },
} = useForm<ContactValues>({
// onBlur = 入力欄からフォーカスが外れた時に検証。打つたびに赤を出さない
mode: "onBlur",
defaultValues: { name: "", email: "", message: "" },
});
const onSubmit = async (values: ContactValues) => {
// await を挟むことで isSubmitting が送信中ずっと true になる
await new Promise((r) => setTimeout(r, 800)); // API送信の代わり
console.log("送信:", values);
reset(); // 送信後にフォームを初期値へ戻す
};
return (
<form onSubmit={handleSubmit(onSubmit)} noValidate>
<div>
<label htmlFor="name">お名前</label>
<input
id="name"
aria-invalid={errors.name ? "true" : "false"}
{...register("name", {
required: "お名前を入力してください",
maxLength: { value: 80, message: "80文字以内で入力してください" },
})}
/>
{errors.name && <p role="alert">{errors.name.message}</p>}
</div>
<div>
<label htmlFor="email">メールアドレス</label>
<input
id="email"
type="email"
aria-invalid={errors.email ? "true" : "false"}
{...register("email", {
required: "メールアドレスを入力してください",
pattern: {
value: /^[^@\s]+@[^@\s]+\.[^@\s]+$/,
message: "メールアドレスの形式が正しくありません",
},
})}
/>
{errors.email && <p role="alert">{errors.email.message}</p>}
</div>
<div>
<label htmlFor="message">本文</label>
<textarea
id="message"
rows={6}
{...register("message", {
required: "本文を入力してください",
minLength: { value: 10, message: "10文字以上で入力してください" },
})}
/>
{errors.message && <p role="alert">{errors.message.message}</p>}
</div>
{/* 送信中はボタンを止めて二重送信を防ぐ */}
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? "送信中..." : "送信する"}
</button>
</form>
);
}
このフォームは入力中いっさい再描画しません。エラーが出る瞬間と、送信ボタンが「送信中…」に変わる瞬間にだけ描画が走る。RHFが「描画の回数」をどこまで削っているかが、触ると分かります。
エラー文をrole="alert"で出し、aria-invalidを立てているのは見た目だけの話ではなく、スクリーンリーダー対応のためです。この辺りはClaude CodeでアクセシブルなUIを実装する手順に詳しく書きました。なお検証を本気でやるなら、画面側のこのルールに加えてサーバー側でも同じ検証を効かせる必要があります。そこはreact-hook-form × zodでフォーム検証を二重化する実装手順を読んでください。
Controller:制御UIライブラリとの橋渡し
ここで多くの人が最初の壁にぶつかります。「素のinputでは動いたのに、MUIの<TextField>にregisterを撒いたら値が反映されない」。
理由は単純で、MUIやAnt Design、react-selectのような部品は内部が制御コンポーネントだからです。RHFがregisterで配るrefを、こうした部品はそのまま入力欄に刺してくれない。だからRHFが値を読めません。非制御の前提が崩れるわけです。
この橋渡し役がControllerです。ControllerはRHFの管理(値・検証・エラー)と、制御部品が求めるvalue/onChangeを翻訳してつなぎます。renderの中で渡ってくるfieldを、部品のvalueとonChangeにそのまま流すのが定石です。
import { useForm, Controller } from "react-hook-form";
import { TextField } from "@mui/material";
type Values = { nickname: string };
export function MuiForm() {
const { control, handleSubmit } = useForm<Values>({
defaultValues: { nickname: "" },
});
return (
<form onSubmit={handleSubmit((v) => console.log(v))}>
<Controller
name="nickname"
control={control}
rules={{ required: "ニックネームは必須です" }}
// field = { value, onChange, onBlur, ref, name }。制御部品にそのまま橋渡し
render={({ field, fieldState }) => (
<TextField
{...field}
label="ニックネーム"
error={!!fieldState.error}
helperText={fieldState.error?.message}
/>
)}
/>
<button type="submit">保存</button>
</form>
);
}
使い分けの基準はシンプルです。素のHTML要素(input/select/textarea)はregister、制御が前提のUIライブラリ部品はController。迷ったら「その部品に直接refを渡せるか」で判断します。渡せるならregister、ダメならController。
注意したいのは、Controllerでラップした部品は値の変更でその部品だけ再描画される点です。非制御の速さが一部戻るので、全フィールドを無条件にControllerで包むのは避けます。制御が必要な部品にだけ使う、が鉄則です。
useFieldArray:行を増減するフォーム
「請求項目を何行でも追加できるフォーム」のような、項目数が動くケース。これをuseStateの配列とmapで自作すると、ほぼ確実に破綻します。追加・削除のたびに親が再描画され、入力中の値が飛んだり、keyの付け方を間違えてReactが行を取り違えたり。僕は一度これでデータ消失バグを出しました。
RHFには専用のuseFieldArrayがあります。fields(描画用の配列)、append(末尾追加)、remove(指定行削除)を返してくれる。fieldsの各要素には自動で一意なidが振られるので、それをkeyに使えば取り違えが起きません。
import { useForm, useFieldArray } from "react-hook-form";
type Invoice = {
items: { label: string; price: number }[];
};
export function InvoiceForm() {
const { register, control, handleSubmit } = useForm<Invoice>({
defaultValues: { items: [{ label: "", price: 0 }] },
});
// 動的な行はこれ1つで管理できる
const { fields, append, remove } = useFieldArray({
control,
name: "items",
});
return (
<form onSubmit={handleSubmit((v) => console.log(v))}>
{fields.map((field, index) => (
// field.id を key にする。index を key にすると削除時にずれる
<div key={field.id}>
<input
placeholder="品目"
{...register(`items.${index}.label`, { required: true })}
/>
<input
type="number"
placeholder="金額"
{...register(`items.${index}.price`, { valueAsNumber: true })}
/>
<button type="button" onClick={() => remove(index)}>
削除
</button>
</div>
))}
<button type="button" onClick={() => append({ label: "", price: 0 })}>
行を追加
</button>
<button type="submit">送信</button>
</form>
);
}
ポイントは2つ。keyには必ずfield.idを使うこと(indexを使うと削除時に行がずれる)。そしてitems.${index}.priceのようにドット記法でネストしたパスをregisterに渡すこと。RHFはこの文字列パスを見て、ネストしたオブジェクト構造に値を組み立てます。valueAsNumber: trueを足すと、number入力が文字列ではなく数値で取れます。
formStateの罠:プロキシによる購読
最後に、ハマる人が多いformStateの挙動です。
formStateにはerrors・isDirty・isSubmitting・touchedFields・isValidなどが入っています。これらを毎回全部更新したら、せっかくの非制御の速さが台無しです。そこでRHFはformStateを**Proxy(プロキシ)**にしていて、「あなたが実際に読んだプロパティ」だけを追跡します。
具体的にはこう動きます。
const { isDirty } = formStateのように分割代入で読むと、その瞬間にRHFが「このコンポーネントはisDirtyを見ている」と記録し、isDirtyが変わったときだけ再描画する。- 逆に読んでいないプロパティは追跡されず、再描画も起きない。
- だから
const isDirty = formState.isDirtyを条件分岐の中など「読まれないことがある場所」に書くと、購読が登録されず、画面が更新されないバグになる。
つまり「使うものはコンポーネント本体で素直に分割代入しておく」のが安全策です。下が良い例と悪い例。
// 良い例:分割代入で読む → 購読が登録され、変化で再描画される
const {
formState: { isDirty, isValid },
} = useForm({ mode: "onChange" });
// 悪い例:条件の中でだけ読む → 購読が登録されず更新が来ないことがある
function onClick() {
if (formState.isDirty) {
/* ここでしか読まないと追跡が始まらない場合がある */
}
}
isValidを使うならmode: "onChange"(またはonBlur)が要る、という別の落とし穴もあります。デフォルトのonSubmitモードでは、送信するまで検証が走らないのでisValidが更新されません。「送信ボタンをisValidで出し分けたいのに反応しない」はこれが原因です。
Claude Codeにフォームを頼むときは、この辺りを契約として渡すと事故が減ります。たとえば「非制御で組む、MUI部品だけController、動的行はuseFieldArray、isValidを使うのでmode: onChange」と書く。Claude Codeのフォーム実装をチームの型に落とす進め方はClaude CodeでReact開発を加速するワークフローにまとめています。
よくある質問
Q. registerとControllerはどちらを使えばいいですか。
A. 素のHTML要素(input/select/textarea)にはregister、MUIやreact-selectのような制御前提のUIライブラリ部品にはControllerです。判断基準は「その部品に直接refを渡せるか」。渡せるならregister、ダメならControllerを使います。
Q. watchを使うと重くなると聞きました。本当ですか。
A. watchはその値を購読し、変わるたびにそのコンポーネントを再描画します。1〜2項目を監視する程度なら問題ありませんが、フォーム全体をwatch()で監視すると非制御の利点が消えます。特定フィールドだけ見たいならwatch("email")のように対象を絞るか、再描画を伴わないgetValuesを検討してください。
Q. defaultValuesは後から変えられますか。
A. APIから取得したデータを入れたい場合は、reset(取得データ)を使います。useFormのdefaultValuesに直接後から代入しても反映されません。非同期で初期値を入れるときはresetが正攻法です。
Q. number入力が文字列で取れてしまいます。
A. register("price", { valueAsNumber: true })を付けてください。HTMLのinputは値を文字列で持つため、何もしないと"100"のように文字列で届きます。日付ならvalueAsDateもあります。
Q. zodでの検証ルールはここに書いていないのですか。
A. はい、意図的に分けています。この記事はRHF本体の使い方とパフォーマンスに集中し、zodスキーマでの検証・型の共有・サーバー側の二重検証はreact-hook-form × zodでフォーム検証を二重化する実装手順にまとめました。zodResolverの繋ぎ込みもそちらにあります。
実際に試した結果
冒頭の「15項目でカクつくフォーム」を、僕は最終的にRHFで作り直しました。効果がいちばん大きかったのは、実はuseFieldArrayへの置き換えです。自作のuseState配列でやっていた動的行をfields/append/removeに寄せただけで、行を追加したときの全体再描画と、削除時に値がずれるバグが両方消えました。
次に効いたのがformStateの読み方を直したこと。isValidが反応しないと悩んでいた原因は、ただmodeをonChangeにしていなかっただけでした。プロキシで購読される、という仕組みを知ってからは、formStateまわりで詰まらなくなった。
結論はシンプルです。RHFは「Reactにフォームの値を持たせない」という一点に賭けたライブラリ。その前提さえ握れば、registerもControllerもuseFieldArrayも、なぜそういう形なのかが全部つながります。仕組みから先に理解する。遠回りに見えて、これがいちばん速い。
公式の一次情報はこちらが起点です。useFormの全オプションはReact Hook Form公式の useForm ドキュメント、動的フィールドはuseFieldArray ドキュメントを確認してください。さらに手を動かして固めたい人は教材一覧にプロンプトテンプレートと実装教材を置いています。
無料PDF: Claude Code はじめてのチートシート
まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。
スパムは送りません。登録情報は厳重に管理します。
Claude Codeを仕事で使える形にしませんか?
まず無料PDFで基本を固め、繰り返し使う作業はGumroad教材へ、チーム導入や権限設計は導入相談へ進めます。
この記事を書いた人
Masa
Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。
関連書籍・参考図書
この記事のテーマに関連する書籍を楽天ブックスで探せます。
※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。
関連記事
制作会社がClaude Codeに触らせる前に決める権限チェックリスト
クライアントサイトを壊さずにAI編集を使うための、制作会社向け権限と確認の型です。
SaaSサポートのバグ報告をClaude Codeで再現手順に変える実務フロー
問い合わせ文をそのまま開発へ投げず、再現手順、証拠、次の一手に整えるサポート向け手順です。
Obsidianの古いメモをClaude Codeの指示書に変える10分ルーチン
Obsidianに溜めたメモが毎回ゴミになる人へ。事実・決定・未確認に仕分けして、Claude Codeがそのまま動ける指示書に変える朝の10分の型を紹介します。