Diving Deep into React Hook Form's form.watch() — What I Learned Implementing Auto-Search Functionality

Diving Deep into React Hook Form's form.watch() — What I Learned Implementing Auto-Search Functionality

React Hook Form's form.watch() has two modes: render-level with re-rendering and subscription-based without re-rendering. We will explain how to implement an automatic search feature triggered by filter changes, while also covering a design improvement that integrates the type filter into a single unified path.
2026.06.19

This page has been translated by machine translation. View original

Introduction

When implementing an auto-search feature triggered by filter changes on a search screen, I had the opportunity to deeply investigate the behavior of form.watch().

"How does form.watch() actually work?" "Are values changed via setValue() also detected?" "How is it different from Next.js Server Actions?" — These questions kept coming up, so I'd like to organize 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 significantly depending on how they are used.

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 where it is called 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 showing or hiding UI elements based on a field's value (e.g., "display a text input when field A is 'other'").

// Example: display a free-text input when category is "Other"
const category = form.watch("category");

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

While convenient, you need to be mindful of the performance impact, since the component tree re-renders every time a value changes.

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

useEffect(() => {
  const subscription = form.watch((values, { name, type }) => {
    // execute side effects inside the callback
  });
  return () => subscription.unsubscribe();
}, [form]);

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

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

I adopted this approach for the auto-search feature. Since the only requirement is to debounce a search execution when a filter changes, there is no need to re-render the UI.

Why does the presence or absence of re-rendering differ?

This relates to RHF's design philosophy. RHF manages form state using internal refs rather than React state. This prevents React from re-rendering on every keystroke.

  • Mode 1 "pulls" form values into React's rendering cycle, causing re-renders.
  • Mode 2 registers a callback against RHF's internal store and operates outside React's rendering cycle.

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

The subscription-based watch callback receives name and type information.

form.watch((_values, { name, type }) => {
  console.log(name); // the name of the changed field (e.g., "filter.language")
  console.log(type); // the source of the change
});

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

Change method type name
User interaction on the UI (input, checkbox click, etc.) "change" field name
form.setValue("field", value) undefined field name
form.reset() undefined undefined

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

This difference had a direct impact on the implementation approach for auto-search.

First approach: type filtering + explicit calls

In the first approach I implemented, I filtered by type === "change" and used watch to capture only user interactions.

// detect only user interactions via 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 was that programmatic changes via setValue() or reset() are not detected by watch as type === "change". This required adding explicit searchDebounce calls to each handler.

// Keyphrase change — setValue doesn't pass the watch type filter, so call explicitly
const onChangeKeyphrase = useCallback((keyphrase: string) => {
  form.setValue("keyphrase", keyphrase);
  searchDebounce(() => onSubmitRaw(), SEARCH_DEBOUNCE_MS); // explicit
}, [...]);

// Filter clear — same applies to reset
const onClickFilterClear = useCallback(() => {
  form.reset();
  searchDebounce(() => onSubmitRaw(), SEARCH_DEBOUNCE_MS); // explicit
}, [...]);

// Model selection — same since it uses setValue
const onSelectModel = useCallback((model) => {
  form.setValue("filter.modelCode", model.code);
  searchDebounce(() => onSubmitRaw(), SEARCH_DEBOUNCE_MS); // explicit
}, [...]);

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 type filtering and consolidate into a single path

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

By removing the type filter, watch detects all changes (user interactions, setValue, and reset). This eliminates the need to write explicit searchDebounce calls in each handler.

useEffect(() => {
  const subscription = form.watch(() => {
    if (!form.getValues("keyphrase")) return; // don't search if there's no keyphrase
    searchDebounce(() => onSubmitRaw(), SEARCH_DEBOUNCE_MS);
  });
  return () => subscription.unsubscribe();
}, [form, searchDebounce, onSubmitRaw]);

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

const onChangeKeyphrase = useCallback((keyphrase: string) => {
  form.setValue("keyphrase", keyphrase);
  // no need to call searchDebounce — watch will detect it
}, [form]);

const onClickFilterClear = useCallback(() => {
  form.reset();
  // watch will detect it
}, [form]);

const onSelectModel = useCallback((model) => {
  form.setValue("filter.modelCode", model.code);
  // watch will detect it
}, [form]);

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

Change source 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 fields must be registered with the RHF form instance. There are two ways to register them.

register() — for uncontrolled components

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

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

Controller — for controlled components

For components like MUI or custom components that cannot directly accept a ref, use Controller (or FormField).

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

Fields that are not registered are invisible to watch. Changes to fields managed by their own useState, or <input> elements not connected to the form, will not be detected.

React Hook Form vs Next.js Server Actions

While investigating RHF's form.watch(), I found myself getting confused with Next.js Server Actions, so let me clarify the differences.

React Hook Form Next.js Server Actions
Execution location Client (browser) Server
Form state JS object (in browser) Sent to server as FormData
Validation Client-side (+ optionally server-side) Server-side
watch() Monitors field changes in real time Concept does not 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 returns a result. There is no mechanism to monitor field changes in real time on the client side.

RHF and Server Actions can be used together. A common setup is to have RHF handle the client-side UX (validation, watch, conditional UI) and call a Server Action on submission. However, watch() is purely a client-side feature of RHF.

Summary

Concept Key point
render-level watch form.watch("field") — with re-rendering. Useful for conditional UI
subscription watch form.watch(callback) — without re-rendering. Ideal for side effects
type field User interactions yield "change", setValue/reset yield undefined
Design simplification Remove type filtering and detect all changes with a single watch. Control with guard conditions
Prerequisites 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. Entirely different concepts

Through implementing the auto-search feature triggered by filter changes, I was able to develop a deep understanding of form.watch()'s behavior. In particular, the approach of removing the type filter and consolidating into a single path not only improves code readability, but also offers an extensibility advantage: any newly added fields are automatically included in what watch detects.

Share this article