
Diving Deep into React Hook Form's form.watch() — What I Learned Implementing Auto-Search Functionality
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.

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
typebehavior is consistent across RHF v7 in general. Even if you pass options such as{ shouldDirty: true }tosetValue,typeremainsundefined.
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.
- The watch subscription (user interactions)
- Explicit
searchDebouncecalls in each handler (programmatic changes)
Improvement: remove type filtering and consolidate into a single path

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.