splitforms.com
All articles/ TUTORIALS9 MIN READPublished May 15, 2026

React Hook Form: watch, setValue, and Conditional Fields (2026)

How to use React Hook Form's watch() and setValue() to build conditional fields, cross-field calculations, and reactive form state without re-renders.

✶ Written by
splitforms.com / blog

Founder of splitforms — the form backend API for developers. Writes about form UX, anti-spam, and shipping web apps without backend code.

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 over watch() in the parent for any form with more than 5 fields.
  • Pass a specific field name to watch('email') — calling watch() 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 Controller only for inputs that need it (Material UI, React Select, etc.). Native HTML inputs work with register() 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.
About the author
✻ ✻ ✻

Get your free contact form API key in 60 seconds.

1,000 free form submissions per month. No credit card. No SDK, no PHP, no plugin. Drop one POST endpoint in your form and submissions land in your inbox.

Generate access key →Read the docs
v0.1 · founders pricing locked in · early access open