Choosing watch or watchEffect to fire form validation in Vue 3 decides whether your errors update once per real change or fire spuriously on mount and re-run on every unrelated state mutation.

This page assumes you are building on the composition-API adapter described in Vue composition API form adapters, and it drills into the one decision that trips up production forms: which watcher primitive drives validation, with what flush timing, and how to keep it from double-firing. If your validation is asynchronous, pair this with the cancellation patterns in asynchronous validation strategies.


Context and prerequisites

Both primitives observe reactive state and run a callback, but they differ on three axes that matter for validation: dependency source (explicit list vs auto-tracked reads), initial run (watch is lazy by default, watchEffect is eager), and access to the previous value (watch gives you (newVal, oldVal), watchEffect gives you neither). Validation almost always wants explicit deps, lazy firing, and the old value — which is why watch is the default answer and watchEffect is the exception. The rest of this page justifies that and shows the exceptions.


The decision, as one focused adapter

import { reactive, ref, watch, watchEffect, onWatcherCleanup } from "vue";

interface SignupForm {
  email: string;
  username: string;
}

export function useValidation(form: SignupForm & Record<string, unknown>) {
  const errors = reactive<Partial<Record<keyof SignupForm, string>>>({});

  // --- CASE 1: watch — the correct default for validation ---------------
  // Explicit source (a getter returning the field). The callback is LAZY:
  // it does NOT run on mount, so a pristine field shows no premature error.
  const stopEmail = watch(
    () => form.email,
    (value, previous) => {
      // The old value lets us short-circuit no-op notifications (e.g. an
      // IME composition event that re-sets the same string).
      if (value === previous) return;
      errors.email = value.includes("@") ? "" : "Enter a valid email";
    },
    // flush: 'pre' (the default) runs before re-render, so the DOM paints
    // the new error in a single pass. See the flush section below.
    { flush: "pre" }
  );

  // --- CASE 2: watch multiple sources for cross-field rules -------------
  // An array source fires when EITHER changes and gives you tuples of
  // new/old values, which single-source watchEffect cannot express cleanly.
  watch(
    [() => form.username, () => form.email],
    ([username, email]) => {
      errors.username =
        username && username === email.split("@")[0]
          ? "Username must differ from your email handle"
          : "";
    }
  );

  // --- CASE 3: watchEffect — only for eager, read-only derivations ------
  // Legitimate use: mirror validity into an aria-live status string. It reads
  // `errors` and writes a DIFFERENT ref, so there is no self-feedback loop.
  const statusMessage = ref("");
  watchEffect(() => {
    const count = Object.values(errors).filter(Boolean).length;
    // Every reactive value READ here becomes a dependency automatically.
    // We deliberately read only `errors`, never write to it.
    statusMessage.value = count === 0 ? "" : `${count} field(s) need attention`;
  });

  // --- CASE 4: async validation with cancellation ----------------------
  watch(
    () => form.username,
    (username) => {
      if (!username) return;
      // AbortController cancels the previous in-flight request when the
      // field changes again, so a slow earlier response cannot overwrite a
      // newer one (the classic stale-async race).
      const controller = new AbortController();
      // onWatcherCleanup runs before the next invocation and on stop; it is
      // the flush-safe replacement for tracking the controller in a ref.
      onWatcherCleanup(() => controller.abort());
      fetch(`/api/username-available?u=${encodeURIComponent(username)}`, {
        signal: controller.signal,
      })
        .then((r) => r.json())
        .then((res) => {
          errors.username = res.available ? "" : "Username taken";
        })
        .catch((err) => {
          if (err.name !== "AbortError") errors.username = "Check failed";
        });
    }
  );

  // Return the stop handle for the one watcher a caller might stop early
  // (e.g. after the field is confirmed valid once).
  return { errors, statusMessage, stopEmail };
}

Step-by-step walkthrough

  1. Enumerate the reactive sources that should re-run validation for the field. For a single field it is just that field; for a rule spanning two fields it is both. Writing them down decides whether you can even use watchEffect (you can only if you are comfortable auto-tracking whatever the callback happens to read).

  2. Reach for watch first. Give it a getter source () => form.field, take (value, previous), and short-circuit when they are equal. This is lazy, so pristine fields do not flash errors on mount — the behavior users expect and the reason watch beats watchEffect here.

  3. Use an array source for cross-field rules. watch([() => a, () => b], ([a, b]) => …) fires on either change and hands you both current values. Expressing this with watchEffect forces you to read both inside the body and accept eager firing.

  4. Confine watchEffect to eager, read-only derivations such as an aria-live summary string. It must not write to any reactive source it also reads.

  5. Pick a flush mode deliberately and stop watchers you created outside synchronous setup.


Before timing, the choice itself. The two differ in one respect that decides almost every case: who names the dependencies.

watchEffect infers dependencies; watch is told them Dependencies: watchEffect tracks whatever the callback reads on its last run, while watch uses exactly the sources you list. First run: watchEffect always runs immediately, while watch waits for a change unless immediate is set. Previous value: watchEffect has none, while watch receives the old value as its second argument. Conditional reads: a dependency read inside a branch that was not taken is not tracked by watchEffect, so the effect stops reacting to it — a real and silent failure — while watch is unaffected. Suits: watchEffect for validation that always reads the same handful of fields, watch for cross-field rules where you need the previous value or must not run on mount. Property watchEffect watch dependencies whatever the last run read exactly the sources listed runs on mount always only with immediate: true previous value not available second argument reads inside a branch untracked if not taken unaffected The last row is the one that produces a validator that "works, then stops": a field read only in the else branch is dropped. Rule of thumb: unconditional reads of a fixed set, watchEffect. Anything conditional or comparative, watch.

Flush timing

flush controls when in the update cycle the callback runs:

  • pre (default) — before the component re-renders. Correct for computing error state, because Vue then renders the field and its error in one pass rather than painting twice.
  • post — after the DOM has been patched. Use it only when validation must read the updated DOM: measuring a rendered element, or moving focus to a newly revealed error. This is the mode to use when your logic coordinates with the error state mapping patterns that render the message element.
  • sync — fires synchronously on every mutation, before batching. It defeats Vue’s coalescing and can run many times per interaction; reserve it for cases that genuinely cannot wait a microtask.

The conditional-dependency failure is worth drawing, because the code that produces it looks completely reasonable:

A dependency that was never read is a dependency you do not have Run one, on mount: country is empty, so the branch that reads postcode is not taken, and the tracked set contains country only. The reader then edits postcode: because postcode is not in the tracked set, the effect does not run and no validation happens. The reader sets country to a value that requires a postcode: country is tracked, so the effect runs, now reads postcode, and postcode joins the tracked set. From this point the effect behaves correctly — which is why the bug is so often reported as intermittent. run 1 · on mount country is empty branch not taken tracked: { country } reader edits postcode postcode is not tracked effect does not run no validation, no error reader sets country country IS tracked effect runs, reads postcode tracked: { country, postcode } Two fixes, both one line Read every dependency before the branch, or use watch([country, postcode], …) and declare them. Reported as "validation is flaky": it is deterministic, but the determining factor is the order the reader filled the form in. A test that fills fields top to bottom will never reproduce it; fill postcode first and it fails every time.

Failure modes and fixes

1. watchEffect fires on mount and shows premature errors

watchEffect runs immediately. If it writes errors.email, a pristine form shows a required-field error before the user types.

// FIX: use watch (lazy). It does not run until form.email actually changes.
watch(() => form.email, (v) => { errors.email = v ? "" : "Required"; });

2. Double-fire from a self-referential watchEffect

Reading and writing the same reactive object inside watchEffect creates a loop:

// BROKEN: reads errors, writes errors → re-triggers itself.
watchEffect(() => { errors.count = Object.keys(errors).length; });

Use watch with an explicit source, or ensure the effect writes to a different ref than any it reads (as statusMessage does above).

3. Watcher created in an async callback never stops

A watcher set up inside a setTimeout, promise, or event handler is not bound to the component and leaks past unmount.

// FIX: capture and store the stop handle; call it on unmount.
let stop: (() => void) | undefined;
onMounted(async () => {
  await ready();
  stop = watch(() => form.email, validateEmail);
});
onUnmounted(() => stop?.());

4. Deep object field not detected

watch(() => form.address, …) with a getter returning the same object reference will not fire on nested mutation.

// FIX: add deep, or watch a specific nested getter instead.
watch(() => form.address, onChange, { deep: true });

5. Stale async result overwrites a newer one

Without cancellation, a slow earlier request resolves after a faster later one and clobbers current state. Use AbortController with onWatcherCleanup as shown in Case 4 above; do not track the controller in an ad-hoc ref, because cleanup ordering with flush timing gets subtle.


Async validation adds one more requirement that neither option handles by itself: the run you started may not be the run whose answer you want.

The last answer to arrive is not the answer you want Without cleanup: run A starts for the value "ad" and takes eight hundred milliseconds. Run B starts for "ada" and takes two hundred. B resolves first and writes the correct result, then A resolves and overwrites it with a result for a value the field no longer holds. With cleanup registered through the watcher's cleanup hook: starting run B aborts run A's request, so A never resolves, and the field ends on B's answer. The general rule is that the guard belongs in the watcher, not in the validator, because only the watcher knows a newer run has begun. no cleanup — the slow answer wins run A ("ad") starts — 800ms run B ("ada") starts — 200ms B resolves: correct result written A resolves: overwrites it, stale with cleanup — A never resolves run A ("ad") starts — 800ms run B starts, cleanup aborts A B resolves: correct result written nothing else can write after it Why the guard belongs in the watcher Only the watcher knows a newer run has started. A validator that checks "is my value still current" has to reach back into form state, which couples it to the form and makes it untestable in isolation. Both watch and watchEffect give you the cleanup hook, so this is one thing the choice above does not affect.

Verification checklist


Frequently Asked Questions

Should I use watch or watchEffect to trigger field validation?

Use watch for validation. You almost always need the new value (and often the old value to short-circuit no-op changes), you usually want it lazy so it does not fire on initial render before the user has touched the field, and an explicit source list prevents accidental dependencies on unrelated reactive state. watchEffect fits derived read-only side-effects, not gated validation.

Why does my watchEffect fire twice per keystroke?

watchEffect re-runs whenever any reactive value it read on the previous run changes. If your callback reads both the field value and an errors object that it also writes to, you create a feedback loop, or you track more sources than intended. Switch to watch with an explicit source, or narrow the reads so the effect only depends on the single field value.

What flush timing should validation use?

Use the default flush: 'pre' for computing errors, because it runs before the component re-renders so the DOM updates once with the new error state. Use flush: 'post' only when the validation logic must read the already-updated DOM, such as measuring a rendered field or moving focus. Reserve flush: 'sync' for cases needing the reaction before any batching, which is rare and can cause redundant runs.

Do I need to stop watchers manually?

Watchers created synchronously inside setup or <script setup> are bound to the component instance and stop automatically on unmount. You must call the returned stop handle yourself only when you create a watcher asynchronously (inside a promise, timeout, or event callback) or when you want to stop watching before unmount, such as after a one-shot async validation resolves.


Related

Vue Composition API Form Adapters