
React Hook Formのform.watch()を深掘りする — 自動検索機能の実装で学んだこと
はじめに
検索画面にフィルター変更時の自動検索機能を追加する実装を行った際、form.watch()の挙動について深く調べる機会がありました。
「form.watch()ってどう動くの?」「setValue()で変更した値も検知されるの?」「Next.jsのServer Actionsとは何が違うの?」——こんな疑問が次々と出てきたので、調べた結果を整理します。
form.watch()には2つのモードがある
React Hook Form(以下RHF)のform.watch()には、使い方によって再レンダリングへの影響が大きく異なる2つのモードがあります。

モード1: render-level watch(再レンダリングあり)
const value = form.watch("fieldName");
コンポーネント本体で直接呼び出すパターンです。フィールドの現在値を返し、値が変わるたびにコンポーネントが再レンダリングされます。
用途としては、フィールドの値に応じてUIを出し分けるケース(「フィールドAが"other"のとき、テキスト入力を表示する」など)が典型的です。
// 例: カテゴリが「その他」のとき自由入力欄を表示
const category = form.watch("category");
return (
<>
<CategorySelect />
{category === "other" && <Input {...form.register("customCategory")} />}
</>
);
便利ですが、値が変わるたびにコンポーネントツリーが再レンダリングされるため、パフォーマンスへの影響を意識する必要があります。
モード2: subscription-based watch(再レンダリングなし)
useEffect(() => {
const subscription = form.watch((values, { name, type }) => {
// コールバック内でside effectを実行
});
return () => subscription.unsubscribe();
}, [form]);
依存配列の[form]について補足すると、useForm()の返り値は安定した参照なので再生成されません。ESLintのreact-hooks/exhaustive-depsルールを満たすために明示的に含めています。
useEffect内でコールバックを渡すパターンです。フォーム値の変更をリッスンしますが、Reactの再レンダリングは一切発生しません。RHFの内部ストアに対する純粋なイベントリスナーです。
今回の自動検索機能ではこちらを採用しました。フィルター変更時に検索をデバウンス実行するだけなので、UIの再レンダリングは不要だからです。
なぜ再レンダリングの有無が異なるのか
RHFの設計思想に関係します。RHFはフォーム状態をReactのstateではなく、内部のrefで管理しています。これにより、フィールドへの入力のたびにReactの再レンダリングが走ることを防いでいます。
- モード1は、Reactのレンダリングサイクルにフォーム値を「引き込む」ため、再レンダリングが発生する
- モード2は、RHFの内部ストアに対するコールバック登録であり、Reactのレンダリングサイクルの外側で動作する
typeフィールド: 変更の「発生源」を見分ける
subscription-based watchのコールバックには、nameとtypeという情報が渡されます。
form.watch((_values, { name, type }) => {
console.log(name); // 変更されたフィールド名(例: "filter.language")
console.log(type); // 変更の発生源
});
typeの値は、フォーム値がどのように変更されたかによって異なります。
| 変更方法 | type |
name |
|---|---|---|
| ユーザーがUI上で操作(入力、チェックボックスクリック等) | "change" |
フィールド名 |
form.setValue("field", value) |
undefined |
フィールド名 |
form.reset() |
undefined |
undefined |
補足: この
typeの挙動はRHF v7系全般で共通です。setValueに{ shouldDirty: true }等のオプションを渡してもtypeはundefinedのままです。
この違いが、自動検索の実装方針に直結しました。
最初のアプローチ: typeフィルター + 明示的呼び出し
最初に実装したアプローチでは、type === "change"でフィルターして、ユーザー操作のみをwatchで拾いました。
// watchでユーザー操作のみ検知
useEffect(() => {
const subscription = form.watch((_values, { type }) => {
if (type !== "change") return;
searchDebounce(() => onSubmitRaw(), SEARCH_DEBOUNCE_MS);
});
return () => subscription.unsubscribe();
}, [form, searchDebounce, onSubmitRaw]);
問題は、setValue()やreset()によるプログラム的な変更がwatchでtype === "change"として検知されないことです。そのため、各ハンドラーに明示的なsearchDebounce呼び出しを追加する必要がありました。
// キーフレーズ変更 — setValueはwatchのtypeフィルターを通らないため明示的に呼ぶ
const onChangeKeyphrase = useCallback((keyphrase: string) => {
form.setValue("keyphrase", keyphrase);
searchDebounce(() => onSubmitRaw(), SEARCH_DEBOUNCE_MS); // 明示的
}, [...]);
// フィルタークリア — resetも同様
const onClickFilterClear = useCallback(() => {
form.reset();
searchDebounce(() => onSubmitRaw(), SEARCH_DEBOUNCE_MS); // 明示的
}, [...]);
// モデル選択 — setValueなので同様
const onSelectModel = useCallback((model) => {
form.setValue("filter.modelCode", model.code);
searchDebounce(() => onSubmitRaw(), SEARCH_DEBOUNCE_MS); // 明示的
}, [...]);
動作はしますが、検索トリガーのパスが2つに分散していました。
- watchのsubscription(ユーザー操作)
- 各ハンドラーの明示的な
searchDebounce呼び出し(プログラム的変更)
改善: typeフィルターを外し、単一パスに統合

typeのフィルターを外せば、watchはすべての変更(ユーザー操作・setValue・reset)を検知します。そうすると、各ハンドラーに明示的なsearchDebounceを書く必要がなくなります。
useEffect(() => {
const subscription = form.watch(() => {
if (!form.getValues("keyphrase")) return; // キーフレーズがなければ検索しない
searchDebounce(() => onSubmitRaw(), SEARCH_DEBOUNCE_MS);
});
return () => subscription.unsubscribe();
}, [form, searchDebounce, onSubmitRaw]);
各ハンドラーは自身の責務(値の更新)だけに集中できます。
const onChangeKeyphrase = useCallback((keyphrase: string) => {
form.setValue("keyphrase", keyphrase);
// searchDebounceの呼び出しは不要 — watchが検知する
}, [form]);
const onClickFilterClear = useCallback(() => {
form.reset();
// watchが検知する
}, [form]);
const onSelectModel = useCallback((model) => {
form.setValue("filter.modelCode", model.code);
// watchが検知する
}, [form]);
1つのwatchですべてをカバー。キーフレーズの存在チェックがガードになり、検索すべきキーワードがない状態での無駄な検索も防げます。
| 変更元 | フロー |
|---|---|
| ユーザーがチェックボックスをクリック | watch発火 → keyphrase確認 → searchDebounce |
onChangeKeyphraseがsetValue |
watch発火 → keyphrase確認 → searchDebounce |
onSelectModelがsetValue |
watch発火 → keyphrase確認 → searchDebounce |
onClickFilterClearがreset |
watch発火 → keyphrase確認(空ならスキップ) |
form.watch()の前提条件
form.watch()ですべてのフィールド変更を検知するには、フィールドがRHFのフォームインスタンスに登録されている必要があります。登録方法は2つあります。
register() — 非制御コンポーネント向け
<input {...form.register("filter.modelCode")} />
register()はref、onChange、onBlurなどのpropsを返し、DOM要素を直接RHFに接続します。
Controller — 制御コンポーネント向け
MUIやカスタムコンポーネントなど、refを直接受け取れないコンポーネントにはController(またはFormField)を使います。
<FormField
control={form.control}
name="filter.language"
render={({ field }) => (
<Select onValueChange={field.onChange} value={field.value}>
{/* ... */}
</Select>
)}
/>
登録されていないフィールドはwatchから見えません。 独自のuseStateで管理しているフィールドや、フォームに接続されていない<input>の変更は検知されません。
React Hook Form vs Next.js Server Actions
RHFのform.watch()を調べているうちに、Next.jsのServer Actionsと混同しそうになったので、違いを整理します。
| React Hook Form | Next.js Server Actions | |
|---|---|---|
| 実行場所 | クライアント(ブラウザ) | サーバー |
| フォーム状態 | JSオブジェクト(ブラウザ内) | FormDataとしてサーバーに送信 |
| バリデーション | クライアントサイド(+ オプションでサーバー) | サーバーサイド |
watch() |
リアルタイムでフィールド変更を監視 | 概念が存在しない(ライブ状態がない) |
| メンタルモデル | SPAのフォーム管理 | 従来のHTMLフォーム送信 |
Next.jsのuseFormState / useActionState + action={serverAction}は、フォームを送信し、サーバーが処理して結果を返すフローです。クライアント側でフィールドの変更をリアルタイムに監視する仕組みはありません。
RHFとServer Actionsは併用できます。RHFでクライアント側のUX(バリデーション、watch、条件付きUI)を担当し、送信時にServer Actionを呼び出す構成です。ただし、watch()は純粋にクライアントサイドのRHFの機能です。
まとめ
| 概念 | ポイント |
|---|---|
| render-level watch | form.watch("field") — 再レンダリングあり。条件付きUIに便利 |
| subscription watch | form.watch(callback) — 再レンダリングなし。side effectに最適 |
typeフィールド |
ユーザー操作は"change"、setValue/resetはundefined |
| 設計の簡略化 | typeフィルターを外し、単一のwatchで全変更を検知。ガード条件で制御 |
| 前提条件 | register()またはControllerでフォームに接続されたフィールドのみ検知可能 |
| vs Server Actions | RHFはクライアントサイドのフォーム管理、Server Actionsはサーバーサイドの処理。全く別の概念 |
フィルター変更時の自動検索という機能を通じて、form.watch()の挙動を深く理解できました。特にtypeフィルターを外して単一パスに統合するアプローチは、コードの見通しが良くなるだけでなく、新しいフィールドを追加しても自動的に検知対象になるという拡張性の面でもメリットがあります。








