A Deep Dive into React Hook Form's form.watch() — What I Learned from Implementing an Auto-Search Feature

A Deep Dive into React Hook Form's form.watch() — What I Learned from Implementing an Auto-Search Feature

React Hook Form's form.watch() has two modes: render-level with re-renders and subscription-based without. This article covers implementing auto-search on filter changes and simplifying the design by removing the type filter into a single path.
2026.06.19

This page has been translated by machine translation. View original

Introduction

While implementing an auto-search feature that triggers when filters change on a search screen, I had the chance to dig deeply into how form.watch() behaves.

Questions kept coming up one after another: "How does form.watch() work?", "Does it also detect values changed with setValue()?", "How is it different from Next.js Server Actions?" So I've organized what I found.

form.watch() has two modes

React Hook Form (hereafter RHF)'s form.watch() has two modes whose impact on re-rendering differs greatly depending on how you use it.

react-hook-form-watch-auto-search-watch-modes

Mode 1: render-level watch (with re-rendering)

const value = form.watch("fieldName");

This is the pattern of calling it directly in the component body. It returns the current value of the field, and the component re-renders every time the value changes.

A typical use case is switching the UI depending on a field's value (for example, "show a text input when field A is 'other'").

// 例: カテゴリが「その他」のとき自由入力欄を表示
const category = form.watch("category");

return (
  <>
    <CategorySelect />
    {category === "other" && <Input {...form.register("customCategory")} />}
  </>
);

It's convenient, but because the component tree re-renders every time the value changes, you need to be mindful of the performance impact.

Mode 2: subscription-based watch (no re-rendering)

useEffect(() => {
  const subscription = form.watch((values, { name, type }) => {
    // コールバック内でside effectを実行
  });
  return () => subscription.unsubscribe();
}, [form]);

A note on the [form] dependency array: the return value of useForm() is a stable reference, so it isn't recreated. It's included explicitly to satisfy ESLint's react-hooks/exhaustive-deps rule.

This is the pattern of passing a callback inside useEffect. It listens for form value changes, but no React re-rendering occurs at all. It's a pure event listener on RHF's internal store.

I adopted this one for the auto-search feature. All we need is to run a debounced search when a filter changes, so re-rendering the UI is unnecessary.

Why does re-rendering differ?

This relates to RHF's design philosophy. RHF manages form state not with React state, but with internal refs. This prevents React re-renders from running on every input to a field.

  • Mode 1 "pulls" form values into React's rendering cycle, so re-rendering occurs
  • Mode 2 registers a callback on RHF's internal store and operates outside React's rendering cycle

The type field: telling the "source" of a change

The callback of the subscription-based watch receives name and type.

form.watch((_values, { name, type }) => {
  console.log(name); // 変更されたフィールド名(例: "filter.language")
  console.log(type); // 変更の発生源
});

The value of type differs depending on how the form value was changed.

How it changed type name
User interaction in the UI (typing, clicking a checkbox, etc.) "change" Field name
form.setValue("field", value) undefined Field name
form.reset() undefined undefined

Note: This type behavior is common across all RHF v7 versions. Even if you pass options such as { shouldDirty: true } to setValue, type remains undefined.

This difference directly shaped the implementation approach for auto-search.

First approach: type filter + explicit calls

In the first approach I implemented, I filtered with type === "change" so that watch picked up only user interactions.

// watchでユーザー操作のみ検知
useEffect(() => {
  const subscription = form.watch((_values, { type }) => {
    if (type !== "change") return;
    searchDebounce(() => onSubmitRaw(), SEARCH_DEBOUNCE_MS);
  });
  return () => subscription.unsubscribe();
}, [form, searchDebounce, onSubmitRaw]);

The problem is that programmatic changes via setValue() or reset() are not detected by watch as type === "change". Because of this, I had to add explicit searchDebounce calls to each handler.

// キーフレーズ変更 — 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); // 明示的
}, [...]);

It worked, but the search trigger paths were split into two.

  1. The watch subscription (user interactions)
  2. Explicit searchDebounce calls in each handler (programmatic changes)

Improvement: remove the type filter and consolidate into a single path

react-hook-form-watch-auto-search-trigger-flow

If you remove the type filter, watch detects all changes (user interactions, setValue, and reset). That way, you no longer need to write an explicit searchDebounce in each handler.

useEffect(() => {
  const subscription = form.watch(() => {
    if (!form.getValues("keyphrase")) return; // キーフレーズがなければ検索しない
    searchDebounce(() => onSubmitRaw(), SEARCH_DEBOUNCE_MS);
  });
  return () => subscription.unsubscribe();
}, [form, searchDebounce, onSubmitRaw]);

Each handler can focus solely on its own responsibility (updating values).

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]);

A single watch covers everything. The keyphrase existence check acts as a guard, which also prevents wasteful searches when there is no keyword to search for.

Source of change Flow
User clicks a checkbox watch fires → check keyphrase → searchDebounce
onChangeKeyphrase calls setValue watch fires → check keyphrase → searchDebounce
onSelectModel calls setValue watch fires → check keyphrase → searchDebounce
onClickFilterClear calls reset watch fires → check keyphrase (skip if empty)

Prerequisites for form.watch()

For form.watch() to detect all field changes, the field must be registered with the RHF form instance. There are two ways to register.

register() — for uncontrolled components

<input {...form.register("filter.modelCode")} />

register() returns props such as ref, onChange, and onBlur, and connects the DOM element directly to RHF.

Controller — for controlled components

For components that can't receive a ref directly, such as MUI or custom components, use Controller (or FormField).

<FormField
  control={form.control}
  name="filter.language"
  render={({ field }) => (
    <Select onValueChange={field.onChange} value={field.value}>
      {/* ... */}
    </Select>
  )}
/>

Unregistered fields are invisible to watch. Changes to fields managed with your own useState, or to an <input> not connected to the form, are not detected.

React Hook Form vs Next.js Server Actions

While looking into RHF's form.watch(), I nearly confused it with Next.js Server Actions, so I'll organize the differences.

React Hook Form Next.js Server Actions
Where it runs Client (browser) Server
Form state JS object (in the browser) Sent to the server as FormData
Validation Client-side (+ optionally server) Server-side
watch() Monitors field changes in real time The concept doesn't exist (no live state)
Mental model SPA form management Traditional HTML form submission

Next.js's useFormState / useActionState + action={serverAction} is a flow where the form is submitted, the server processes it, and it returns a result. There is no mechanism for monitoring field changes in real time on the client side.

RHF and Server Actions can be used together. RHF handles the client-side UX (validation, watch, conditional UI), and a Server Action is called on submission. However, watch() is purely a client-side RHF feature.

Summary

Concept Key point
render-level watch form.watch("field") — with re-rendering. Handy for conditional UI
subscription watch form.watch(callback) — no re-rendering. Best for side effects
type field "change" for user interactions, undefined for setValue/reset
Design simplification Remove the type filter and detect all changes with a single watch. Control with guard conditions
Prerequisite Only fields connected to the form via register() or Controller can be detected
vs Server Actions RHF is client-side form management, Server Actions are server-side processing. Completely different concepts

Through the feature of auto-search on filter changes, I was able to deeply understand the behavior of form.watch(). In particular, the approach of removing the type filter and consolidating into a single path not only makes the code easier to follow, but also has the benefit of extensibility: newly added fields automatically become targets of detection.

Share this article