watch() vs useWatch() — the performance tradeoff
react-hook-form ships two ways to subscribe to form values. watch() is a method on the useForm() return value; calling it in your component triggers a re-render of that component every time the watched field changes. useWatch() is a hook you can call inside child components, scoping the re-render to just the child that needs the value.
For small forms (3–5 fields) the difference is invisible. For larger forms — especially ones with expensive validation or rich UI — the gap matters. Switching from watch() to useWatch() in a 15-field form cut our render count by ~80% in a profiler measurement.
watch() in a parent
"use client";
import { useForm } from "react-hook-form";
export default function PlanForm() {
const { register, handleSubmit, watch } = useForm();
const plan = watch("plan"); // entire form re-renders on every form change
return (
<form onSubmit={handleSubmit(console.log)}>
<select {...register("plan")}>
<option value="free">Free</option>
<option value="pro">Pro</option>
<option value="enterprise">Enterprise</option>
</select>
{plan === "enterprise" && (
<input
{...register("seats")}
type="number"
min={5}
placeholder="Number of seats"
/>
)}
<button type="submit">Submit</button>
</form>
);
}useWatch() in a child
"use client";
import { useForm, useWatch, type Control } from "react-hook-form";
function SeatsField({ control }: { control: Control }) {
const plan = useWatch({ control, name: "plan" });
if (plan !== "enterprise") return null;
return <input name="seats" type="number" min={5} placeholder="Seats" />;
}
export default function PlanForm() {
const { register, handleSubmit, control } = useForm();
// Parent never re-renders on `plan` change — only SeatsField does.
return (
<form onSubmit={handleSubmit(console.log)}>
<select {...register("plan")}>
<option value="free">Free</option>
<option value="pro">Pro</option>
<option value="enterprise">Enterprise</option>
</select>
<SeatsField control={control} />
<button type="submit">Submit</button>
</form>
);
}setValue() — and the three flags that matter
setValue(name, value)is the workhorse for programmatic updates. By default it just updates the value — no validation, no dirty tracking, no touched flag. That's deliberate (the most common case is "set this without bothering the user"), but it surprises developers who expect their Zod schema to fire.
// Just update the value. Validation, dirty, touched: untouched.
setValue("city", "Brooklyn");
// Validate against the resolver schema.
setValue("city", "Brooklyn", { shouldValidate: true });
// Mark dirty (form.formState.isDirty becomes true).
setValue("city", "Brooklyn", { shouldDirty: true });
// Mark touched (validation messages start showing).
setValue("city", "Brooklyn", { shouldTouch: true });
// All three — the "user-equivalent" update.
setValue("city", "Brooklyn", {
shouldValidate: true,
shouldDirty: true,
shouldTouch: true,
});Real example — auto-fill city from zip
A common conditional-field pattern: when the user types a zip code, fetch the matching city and state, fill them in automatically. Combine useWatch for the subscription with setValue for the updates.
"use client";
import { useEffect } from "react";
import { useForm, useWatch } from "react-hook-form";
export default function AddressForm() {
const { register, handleSubmit, control, setValue } = useForm();
const zip = useWatch({ control, name: "zip" });
useEffect(() => {
if (!zip || zip.length !== 5) return;
let cancelled = false;
(async () => {
const res = await fetch(`https://api.zippopotam.us/us/${zip}`);
if (!res.ok) return;
const data = await res.json();
if (cancelled) return;
const place = data.places?.[0];
setValue("city", place?.["place name"], { shouldValidate: true });
setValue("state", place?.["state abbreviation"], { shouldValidate: true });
})();
return () => {
cancelled = true;
};
}, [zip, setValue]);
return (
<form onSubmit={handleSubmit(console.log)}>
<input {...register("zip", { required: true, pattern: /^\d{5}$/ })} />
<input {...register("city", { required: true })} />
<input {...register("state", { required: true })} />
<button type="submit">Save</button>
</form>
);
}Note the cancellation flag inside useEffect — if the user types fast, you may have multiple in-flight requests and want to ignore stale responses. Without it you can get a city from an old zip overwriting the correct one.
Wiring the submit to splitforms
The submit handler is the same regardless of whether you used watch or useWatch. Build a FormData from the validated values, POST to https://splitforms.com/api/submit with your access key, and let splitforms handle email, storage, and webhook delivery.
async function onSubmit(data: FormData) {
const fd = new FormData();
fd.append("access_key", "YOUR_ACCESS_KEY");
Object.entries(data).forEach(([k, v]) => fd.append(k, String(v)));
const res = await fetch("https://splitforms.com/api/submit", {
method: "POST",
body: fd,
});
const json = await res.json();
if (json.success) {
// reset(), redirect, or show success UI
} else {
// show error UI
}
}See the React Hook Form + Zod guide for the full validation pattern, and the React contact form quickstart for the minimal version.
Performance checklist
- Prefer
useWatch()in child components overwatch()in the parent for any form with more than 5 fields. - Pass a specific field name to
watch('email')— callingwatch()with no args subscribes to every field change. - When using
setValue()programmatically, decide explicitly whether you want validation/dirty/touched to fire. Don't leave it implicit. - Use
Controlleronly for inputs that need it (Material UI, React Select, etc.). Native HTML inputs work withregister()directly and have lower overhead. - Run the React DevTools profiler before optimizing. Most forms don't need any of these — only optimize when you measure the cost.
FAQ
- What's the difference between watch() and useWatch() in React Hook Form?
- watch() is a method on the useForm() return value; it triggers a re-render of the entire form component whenever the watched field changes. useWatch() is a separate hook you call inside a child component; it only re-renders that specific child, not the entire form. Use useWatch when you want to react to field changes without re-rendering the whole form — much faster for forms with many fields.
- Does setValue() trigger validation?
- Not by default. setValue('field', value) updates the field's value but skips validation, dirty state, and touched state. To trigger them, pass options: setValue('field', value, { shouldValidate: true, shouldDirty: true, shouldTouch: true }). For conditional fields you usually want shouldValidate: true so the new value is checked against your Zod/Yup schema.
- How do I show a field only when another field has a specific value?
- Use useWatch() to subscribe to the controlling field. Inside a child component, call const value = useWatch({ control, name: 'plan' }); then conditionally render the dependent field: {value === 'pro' && <input {...register('team_size')} />}. The parent form doesn't re-render when 'plan' changes — only the child that uses useWatch does.
- Why is my form re-rendering too often when I use watch()?
- watch() in the parent component subscribes to every form value change and re-renders the entire form. For complex forms (10+ fields), that's expensive. Switch to useWatch() in child components so only the children that actually need a value re-render. Or pass a specific field name to watch('email') instead of watch() with no args — the latter watches all fields.
- How do I auto-fill a field based on another field?
- Combine useWatch (or watch) with setValue inside a useEffect. Subscribe to the source field; in useEffect, setValue the derived field. Example: when zip code changes, fetch the city and setValue('city', cityFromZip). Use { shouldValidate: true } so the auto-filled value gets validated against your schema.
- Where does the form data go after submission?
- Wherever your onSubmit handler sends it. The typical splitforms pattern: in onSubmit, build a FormData from the validated values and POST to https://splitforms.com/api/submit with your access_key. splitforms emails you the submission, stores it permanently, and fires webhooks — no backend code required on your end.