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
-
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). -
Reach for
watchfirst. 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 reasonwatchbeatswatchEffecthere. -
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 withwatchEffectforces you to read both inside the body and accept eager firing. -
Confine
watchEffectto eager, read-only derivations such as anaria-livesummary string. It must not write to any reactive source it also reads. -
Pick a flush mode deliberately and stop watchers you created outside synchronous setup.
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.
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.
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.