Effects
An effect runs client code when values change: watch when its sources change, and watchEffect when anything it reads changes. Effects never change what the server renders, and every target runs them with Vue's timing: after the writes that trigger them, once for the writes of one handler, with the previous value and a cleanup.
Watchers
watch(source, callback) calls back when the source's value changes. The callback receives the new value and the previous one:
const zoom = ref(100);
const history = ref<string[]>([]);
watch(zoom, (value, previous) => {
history.value = [...history.value, `${previous}% to ${value}%`];
});
const [zoom, setZoom] = useState(100);
const zoomRef = useRef(zoom);
const [history, setHistory] = useState<string[]>([]);
const historyRef = useRef(history);
const previousZoom = useRef(zoom);
const onZoomChange = useEffectEvent((value: typeof zoom, previous: typeof zoom) => {
historyRef.current = [...historyRef.current, `${previous}% to ${value}%`];
setHistory(historyRef.current);
});
useEffect(() => {
const previous = previousZoom.current;
if (Object.is(previous, zoom)) return;
previousZoom.current = zoom;
onZoomChange(zoom, previous);
}, [zoom]);
- React writes an effect over the source, with the previous value in a ref. The callback runs in an effect event,
useEffectEvent, which reads the latest props and state, so the effect lists only its source. The run at mount records the value and calls nothing back. - Vue copies the watcher as written, but reads a state that may hold an object or an array, a
shallowRef, through a getter,watch(() => messages.value, …): Vue'swatchcalls a shallow ref's watcher back whenever it runs, changed or not. - Svelte writes
$effect.pre, which tracks only the source. The callback runs inuntrack, so what it reads and writes does not run the effect again, and a test withObject.isagainst the value at the last callback skips a run whose value did not change. - Angular creates an
effectinngOnInit, once the inputs are set, over acomputedof the source, which changes only when its value does. The callback runs inuntracked. - Qwik writes
useTask$, which tracks the source, with the previous value in a signal. - Astro drops the watcher: it renders once, on the server.
Solid runs what a write triggers as the write is made, and its on types the previous value as possibly undefined and runs an effect's cleanups whenever the effect runs again. So the file declares a scheduler as Vue's and an inline helper, createWatcher (interactivity, emulated): createReaction queues the watcher when a value its source read is written, and a microtask, queued at the first write, calls each queued watcher back once, where the value differs by Object.is:
/** The watchers whose sources have changed since the last flush, in the order they changed. */
const queuedWatchers = new Set<() => void>();
let flushQueued = false;
/**
* Vue's scheduler: queues a watcher whose sources have changed and, once the synchronous code that
* changed them has finished, runs each queued watcher once, until none is queued: a Set's iteration
* visits what a watcher's writes queue while it runs.
*/
function queueWatcher(run: () => void): void {
queuedWatchers.add(run);
if (flushQueued) return;
flushQueued = true;
queueMicrotask(() => {
try {
for (const watcher of queuedWatchers) {
queuedWatchers.delete(watcher);
watcher();
}
} finally {
flushQueued = false;
}
});
}
/**
* Vue's `watch`: once the value `source` reads has changed (by `Object.is`), calls `callback` back
* when the code that changed it has finished, before the DOM updates, with the value at its last
* callback and a cleanup registrar, whose cleanups run before the next callback and when the
* component is removed.
*/
function createWatcher<T>(
source: () => T,
callback: (value: T, previous: T, onCleanup: (cleanup: () => void) => void) => unknown,
): void {
const cleanups: (() => void)[] = [];
const track = createReaction(() => queueWatcher(run));
let last = read();
onMount(() =>
onCleanup(() => {
queuedWatchers.delete(run);
for (const cleanup of cleanups.splice(0)) cleanup();
}),
);
function read(): T {
let value!: T;
track(() => {
value = source();
});
return value;
}
function run(): void {
const value = read();
if (Object.is(value, last)) return;
const previous = last;
last = value;
for (const cleanup of cleanups.splice(0)) cleanup();
callback(value, previous, (cleanup) => cleanups.push(cleanup));
}
}
A watcher calls back after the writes that trigger it, once for all the writes of one synchronous run of client code, such as a handler up to its first await. previous is the value the source had at the watcher's last callback. A write that leaves the value as it was, or writes it away and back within one run, calls nothing back. Writes separated by an await may call back once or twice, so code must not depend on it. The order in which different watchers run after one handler is not part of the contract (Semantics).
Sources
A source is a ref or a computed itself, a getter, or an array of them. A prop is watched through a getter, () => currency:
watch(
() => high.value - low.value,
(span) => {
emit("spanUpdate", span);
},
);
watch([low, high], ([minimum, maximum]) => {
emit("rangeUpdate", minimum, maximum);
});
watch(
() => currency,
(value) => {
emit("currencyUpdate", value);
},
);
- A getter calls back only when its value changes:
high.value - low.valuestays the same when both move by ten. React computes a getter in auseMemo, Svelte in a$derived, Angular in acomputedand Qwik in auseComputed$, so that the effect compares values, not the writes that made them; Solid'screateWatchercomputes it again when it runs, and compares its value. - An array calls back once when any of its values changes, with the values and the previous values as arrays, which the callback may destructure.
- A prop's getter calls back when the parent passes a new value.
React's output for the getter shows the memo the effect watches:
const watchedSpan = useMemo(() => high - low, [high, low]);
const previousSpan = useRef(watchedSpan);
const onSpanChange = useEffectEvent((span: typeof watchedSpan) => {
onSpanUpdate?.(span);
});
useEffect(() => {
const previous = previousSpan.current;
if (Object.is(previous, watchedSpan)) return;
previousSpan.current = watchedSpan;
onSpanChange(watchedSpan);
}, [watchedSpan]);
A prop's value, watch(page, …), a ref's value, watch(count.value, …), and a constant are values taken once, which nothing can watch: UF2020, with safe fixes to () => page and count. A template ref and a setup let are never sources. A callback or a getter is written in place: watch(count, report) is UF2022, with a fix that wraps it, (value, previous, onCleanup) => report(value, previous, onCleanup).
A getter is pure, as a computed's is. A getter whose value may be an object or an array, and a computed that a source reaches whose value may be, reads its reactive values unconditionally (UF2015): React compares such a value by identity, and recomputes it whenever a value it lists changes.
Immediate watchers and cleanup
{ immediate: true } calls back once when the watcher is created, with previous undefined. The third parameter, onCleanup, registers what to undo before the next callback:
const channel = ref("general");
watch(
() => channel.value.toLowerCase(),
(name, previous, onCleanup) => {
emit("join", name);
onCleanup(() => {
emit("leave", name);
});
},
{ immediate: true },
);
const onLeaveRef = useRef(onLeave);
useLayoutEffect(() => {
onLeaveRef.current = onLeave;
});
const [channel, setChannel] = useState("general");
const channelRef = useRef(channel);
const watchedName = useMemo(() => channel.toLowerCase(), [channel]);
const previousName = useRef<typeof watchedName | undefined>(undefined);
const onNameChange = useEffectEvent(
(
name: typeof watchedName,
previous: typeof watchedName | undefined,
onCleanup: (cleanup: () => void) => void,
) => {
onJoin?.(name);
onCleanup(() => {
onLeaveRef.current?.(name);
});
},
);
useEffect(() => {
const previous = previousName.current;
previousName.current = watchedName;
const cleanups: (() => void)[] = [];
onNameChange(watchedName, previous, (cleanup) => void cleanups.push(cleanup));
return () => {
for (const cleanup of cleanups) cleanup();
};
}, [watchedName]);
A cleanup runs right before the watcher's next callback, and when the component is removed. It never runs on a run that calls nothing back: writing "General" over "general" changes the channel but not the getter's value, so neither the callback nor the cleanup runs.
- React returns the cleanups a run registers as its effect's cleanup, which React runs before the next run and at unmount. A prop that deferred code reads,
onLeavehere, has a mirror ref, so the cleanup calls the latest listener. Where a pre watcher writes state, a post watcher keeps its cleanups in a ref instead, and runs them only before its next callback and at unmount: its effect also runs on renders that call nothing back (Reading the DOM). - Svelte keeps a run's cleanups in a list, which the next call and
onMount's teardown run. - Angular passes its own
onCleanup, and destroys the watcher inngOnDestroy, before Angular stops the outputs, so a cleanup can still emit. An immediate callback runs in the browser only, after the platform check. - Solid keeps a watcher's cleanups in a list that its next callback empties first, and that a cleanup of an
onMountempties when the component is removed. - Qwik keeps the cleanups in a list that the next callback empties first, and a visible task empties it when the component is removed: Qwik's own task cleanup would also run on a run that calls nothing back.
onCleanup is called directly, in the callback's synchronous part, before any await.
Vue runs an immediate watcher's first callback during the setup, on the server too, and Qwik may. So that callback, every function in it, and the local functions it calls must be safe to run there (UF2013): they write no state, read no template ref, use none of the browser's globals or timers, do not await and call no nextTick. Emitting, reading props and state, and console are safe. To set state when the component mounts, derive it with computed, or write it from onMounted and a watcher without immediate.
watchEffect
watchEffect(effect) runs once the component is in the document, and again after any prop, state or derived value that it read changes. Its parameter, onCleanup, registers what to undo before the next run and when the component is removed:
const unread = ref(0);
watchEffect((onCleanup) => {
const title = `(${unread.value}) ${appName}`;
emit("titleChange", title);
onCleanup(() => {
emit("titleRelease", title);
});
});
const onTitleReleaseRef = useRef(onTitleRelease);
useLayoutEffect(() => {
onTitleReleaseRef.current = onTitleRelease;
});
const [unread, setUnread] = useState(0);
const unreadRef = useRef(unread);
const onUnreadAppNameChange = useEffectEvent(
(
unreadValue: typeof unread,
appNameValue: typeof appName,
onCleanup: (cleanup: () => void) => void,
) => {
const title = `(${unreadValue}) ${appNameValue}`;
onTitleChange?.(title);
onCleanup(() => {
onTitleReleaseRef.current?.(title);
});
},
);
useEffect(() => {
const cleanups: (() => void)[] = [];
onUnreadAppNameChange(unread, appName, (cleanup) => void cleanups.push(cleanup));
return () => {
for (const cleanup of cleanups) cleanup();
};
}, [unread, appName]);
- Vue writes
watchPostEffect: an effect runs after the DOM updates on every target, and never on the server. - React writes an effect over the values the body reads, which it passes to an effect event as parameters (
unreadValue,appNameValue), so the effect reads exactly what it lists. A value only a function the body calls reads is passed too, named for what it is (_votes): the body leaves it. - Svelte writes
$effect, which returns its cleanup. - Angular writes
afterRenderEffectin the constructor. One that registers a cleanup is destroyed inngOnDestroy, before Angular stops the outputs. - Solid writes
createWatchEffectover the values the body reads, a helper besidecreateWatcher's scheduler: it runs the body once the component has mounted, and again with the post watchers once one of those values is written. - Qwik writes
useVisibleTask$, which tracks each value the body reads. Qwik may run a visible task twice for one change, so the task keeps the values it last ran for and runs the body only when one changed byObject.is, with its cleanups in a list that the next run and the component's removal empty. - Astro drops it.
Vue tracks what each run reads, but React's effect, Solid's helper and Qwik's track list their dependencies before the effect runs. So a watchEffect reads every prop, state and derived value in the straight-line start of its body (UF2015): before the first if, loop, switch, catch, return, throw or await (that if's test, that loop's head, and that switch's, return's or throw's value included), into plain blocks and a try block up to its first call, outside the right side of &&, ||, ?? and ?:, after no ?., and outside a destructuring default. Read every value into a local first, or watch explicit sources with watch.
A function the effect hands on to run later, to a timer, a promise's then, addEventListener, an observer or onCleanup, runs untracked on every target, so its reads are no dependencies:
watchEffect((onCleanup) => {
if (!checking.value) return;
const check = () => {
checks.value += 1;
emit("checked", checks.value);
};
checker = setInterval(check, 1000);
emit("started");
onCleanup(() => clearInterval(checker));
});
Only checking runs the effect again. check reads and writes checks from the interval, after the effect has run, so a tick does not run the effect again. A setup let and a template ref are never dependencies, so the effect itself reads neither (UF2010); what it hands on may, as the cleanup reads checker.
Vue ignores what an effect writes while it runs, but the other targets would run it again after its own write, without end. So what the effect runs at once, up to its first await, writes no state it reads (UF2027). Watch the sources with watch instead, whose callback is untracked.
Async callbacks
A watcher's callback and watchEffect may be async. Every target runs the part before the first await as the callback is called, onCleanup included, and lets the rest go on by itself, as Vue does. A change while the callback waits runs its cleanups and calls it back at once, so a cleanup can cancel what the earlier call waits for:
watch(shelf, (value) => {
localStorage.setItem("reading-list:shelf", value);
});
watch(query, async (value, previous, onCleanup) => {
const controller = new AbortController();
onCleanup(() => controller.abort());
if (value === "") {
books.value = [];
searching.value = false;
return;
}
const url = `/api/books?${new URLSearchParams({ q: value, shelf: shelf.value })}`;
searching.value = true;
failure.value = "";
emit("requested", url);
try {
const response = await fetch(url, { signal: controller.signal });
const found = (await response.json()) as string[];
books.value = found;
searching.value = false;
emit("found", value, found.length);
} catch (error) {
if (controller.signal.aborted) {
emit("cancelled", value);
return;
}
searching.value = false;
failure.value = error instanceof Error ? error.message : "The search failed";
}
});
- Angular runs the callback as
untracked(async () => …), and Svelte asuntrack(async () => …). An asyncwatchEffectstarts an async function inside the effect,void (async () => { … })(), on Svelte and Angular, so what it reads before its firstawaitis tracked. Solid'screateWatchEffecttakes the values it reads apart, and calls the async function as written. - Qwik runs a task again only once its last run has settled, so its task lets the callback's body go on by itself:
const previousQuery = useSignal(() => query.value);
const queryCleanups = useConstant(() => noSerialize<(() => void)[]>([]));
useTask$(
({ track }) => {
const value = track(query);
if (Object.is(value, previousQuery.value)) return;
previousQuery.value = value;
for (const callback of queryCleanups?.splice(0) ?? []) callback();
const onCleanup = (callback: () => void) => void queryCleanups?.push(callback);
void (async () => {
const controller = new AbortController();
onCleanup(() => controller.abort());
if (value === "") {
books.value = [];
searching.value = false;
return;
}
const url = `/api/books?${new URLSearchParams({ q: value, shelf: shelf.value })}`;
searching.value = true;
failure.value = "";
onRequested$?.(url);
try {
const response = await fetch(url, { signal: controller.signal });
const found = (await response.json()) as string[];
books.value = found;
searching.value = false;
onFound$?.(value, found.length);
} catch (error) {
if (controller.signal.aborted) {
onCancelled$?.(value);
return;
}
searching.value = false;
failure.value = error instanceof Error ? error.message : "The search failed";
}
})();
},
{ deferUpdates: false },
);
The first watcher writes localStorage before the DOM updates. Storage, navigator, history, location and fetch read nothing a render changes, so it needs no flush: "post".
Reading the DOM
A watcher calls back before the DOM updates. Vue's and Svelte's would read the DOM as it was, React's and Solid's as it is now. So a watcher that reads the DOM says flush: "post", and calls back after the DOM updates on every target:
watch(
messages,
() => {
emit("rendered", list.value?.childElementCount ?? 0);
},
{ flush: "post" },
);
const previousMessages = useRef(messages);
const onMessagesChange = useEffectEvent(() => {
onRendered?.(list.current?.childElementCount ?? 0);
});
useEffect(() => {
const previous = previousMessages.current;
if (Object.is(previous, messages)) return;
previousMessages.current = messages;
onMessagesChange();
}, [messages]);
- React's effects run after the DOM updates already, so its watcher keeps its shape. React has no phase before the render, so a watcher without
flush: "post"runs after it too, and its writes render once more: where one writes state, a post watcher andwatchEffectwait until what it wrote is in the DOM, as on Vue, which runs its pre watchers before the render. Where those writes end where they were, so that React would commit nothing, the waiting effect asks for a render of its own. - Solid updates the DOM as each write is made, so every watcher sees it updated;
createWatcherruns the post watchers after the others, which have written by then. - Svelte writes
$effectrather than$effect.pre, AngularafterRenderEffectrather thaneffect, and QwikuseVisibleTask$rather thanuseTask$.
A watcher without flush: "post" whose callback reads a template ref or the DOM through a global (document, window's layout and scroll, getComputedStyle, getSelection), itself or through the functions it calls, is UF2018, with a likely fix that adds { flush: "post" }. What it reads after await nextTick() reads the updated DOM, and needs nothing. immediate with flush: "post" is UF2013: its first callback would run before the DOM exists. watchEffect runs after the DOM updates on every target, and needs nothing.
Options
A watcher's options are an object literal of immediate: true and flush: "post". immediate: false, flush: "pre" and an empty object are the defaults, written as nothing: UF1002, with a safe fix that removes them. watchEffect takes no options, or { flush: "post" }, which it is already.
Not yet
- A watcher's
onceis UF1002, and so is its stop handle: callwatch(…)as a statement. deepandflush: "sync"are UF1002 too, and not planned: state is replaced whole, so a watcher sees every change of its source, and a watcher calls back before the DOM updates or after it, never inside a write.- The order of a parent's and a child's effects lands with child components in M3.
The decision behind this page is ADR-0048.
Events
Listeners take Vue's event names and keep the DOM's semantics, with capture, once and passive options. A component declares the events it emits with defineEmits, and each framework gets its own callbacks.
Lifecycle
onMounted runs once the component's DOM is in the document, onUnmounted when it is removed, and nextTick waits for the DOM to update. None of them runs on the server.