splitforms.com
← Back to the journal

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.

Start free — 200 submissions totalSee pricing →No credit card. Paid plans from $5/mo.

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 guidefor the full validation pattern, the 2026 React form library comparison for choosing a form-state library, and the top 10 React form backends for backend delivery options.

Want the form handling done for you? Get a free splitforms access key — one hidden input, zero config.

Performance checklist

  • Get a free splitforms access key — the submit endpoint is https://splitforms.com/api/submit, and it works with watch() and setValue() output identically.
  • 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.

Related articles

More practical guidance from tutorials.

Browse the journal →
Tutorials

How to Add a Contact Form to a Website Built with Claude, Cursor, Lovable, v0 or Bolt

AI-generated contact forms do nothing on submit because there is no backend. Fix it with one

11 min readRead →
Tutorials

FormData in JavaScript: Submit Forms with fetch (2026 Guide)

The complete FormData guide: reading form fields, appending files, sending multipart and url

9 min readRead →
Tutorials

Custom Auto-Responder Emails for HTML Forms (HTML + CSS, 2026)

Design fully branded auto-responder emails for your HTML forms: paste your own HTML/CSS temp

9 min readRead →

Explore this topic

Start with the overview, then move into focused guides.

OverviewContact Forms for Every FrameworkStart here →GuideContact Form for Next.jsRead →GuideContact Form for ReactRead →GuideContact Form for VueRead →ReferenceContact form guideOpen →

Building forms with ChatGPT, Claude, Cursor, or v0? Connect the native MCP server and give your agent a production form backend.

Explore the MCP server →

Give your form a production backend.

One endpoint adds delivery, spam filtering, storage, and integrations. Start with 200 submissions total for free.

Create free accountRead the docs →
Secure checkoutSSL encryptionPrivacyProtected
VISAAMERICANEXPRESSstripe