In Vue 3, when does watchEffect() run its function, and which reactive reads become its dependencies?
answer
- no source argument
- runs once straight away
- whatever it read last time
- branches change the set
basics
~20 sWith the default flush, watchEffect() runs its function immediately, and its dependencies are every reactive value read during that synchronous run. It re-runs when any of them changes, re-collecting dependencies each time, so conditional reads come and go.
solid answer
~40 s`watchEffect(fn)` takes no source. With the default `flush: 'pre'` it runs `fn` synchronously as soon as it is created, and Vue records every reactive read made during that run - a ref's `.value`, a reactive object's property, a computed, a prop. When any of those changes, the effect is scheduled and runs again, batched, before the owner component re-renders. The dependency set is rebuilt on every run, so in `if (enabled.value) draw(points.value)` the effect only depends on `points` while `enabled` is true. Unlike `watch()`, it is not lazy, receives no new and old values, and only tracks what it actually reads, which can be cheaper than a deep watch of a large object.
code
ts · 14 linesimport { ref, watchEffect } from 'vue'
const enabled = ref(false)
const points = ref<number[]>([])
watchEffect(() => {
console.log('run') // logs once, synchronously
if (enabled.value) {
console.log(points.value.length)
}
})
points.value = [1, 2] // no re-run: points was not read
enabled.value = true // re-runs (batched), now also tracks pointsgo deeper
Know that watchEffect() runs at once and re-runs when anything it read changes, with no source list and no old value.
Explain that dependencies are re-collected each run, how conditional reads add and drop dependencies, and how that differs from watch().
Choose between watchEffect(), watch() and computed by what the side effect needs, and use conditional tracking deliberately rather than by accident.
Set team guidance on when implicit tracking is acceptable versus when an explicit watch source documents intent better for reviewers.
## What `watchEffect()` is `watchEffect(fn, options?)` is Vue 3's auto-tracking watcher. You do not tell it what to watch; it **finds out by running** `fn` and recording which reactive values `fn` reads. It is imported from `vue` and returns a handle for stopping it. ## When it runs - **First run:** with the default `flush: 'pre'`, `fn` runs **synchronously**, during the `watchEffect()` call itself. There is no `immediate` option because running at once is the default. (A watcher created with `flush: 'post'` is the exception: its first run waits for the post-render queue.) - **Later runs:** when a dependency changes, the effect is not run on the spot. It is **scheduled**, deduplicated, and run in a batch - with the default flush, after parent component updates and before the owner component's DOM update. - **End:** a watcher created synchronously in a component's setup is stopped when the component unmounts. ## What it tracks While `fn` runs, every **reactive read** registers a dependency: - `count.value` on a ref; - `state.filter` on a `reactive()` object, including nested properties you actually touch; - `total.value` on a `computed`; - `props.size` on the props object. Plain variables, non-reactive objects, and values read with reactivity bypassed are not tracked. ## Dependencies are rebuilt every run Vue re-collects the dependency set on **every** run and drops the ones that were not read this time: ```ts watchEffect(() => { if (enabled.value) { draw(points.value) } }) ``` 1. First run with `enabled` false: only `enabled` is read, so only `enabled` is a dependency. Changing `points` does nothing. 2. `enabled` becomes true: the effect re-runs, reads `points`, and now depends on both. 3. `enabled` goes back to false: the next run does not read `points`, so that dependency is dropped again. This is correct behaviour - when the branch is off, `points` cannot affect the result - but it surprises people who expect a fixed list. ## Compared with `watch()` | | `watchEffect(fn)` | `watch(source, cb)` | |---|---|---| | Dependencies | whatever `fn` reads | only the declared source | | First run | immediately (default flush) | lazy unless `immediate: true` | | Callback arguments | none besides the cleanup registrar | new value, old value, cleanup registrar | | Fires when | any tracked read changes | the source value changed | | Nested data | tracks only properties read | needs `deep` to see nested changes | The Vue guide points out that for nested data `watchEffect()` can be **more efficient than a deep watcher**, because it tracks only the properties used rather than traversing everything. ## Reads that are easy to misjudge - **Length versus items.** `items.value.length` tracks the length only; setting `items.value[0].done = true` does not change the length, so the effect does not re-run. - **Serialising tracks everything.** `JSON.stringify(state)` reads every nested property, so the effect now depends on the whole structure - convenient, but as broad as a deep watch. - **Deferred callbacks.** A read inside a `setTimeout` or event-listener callback created by the effect runs later, outside the effect, and is not tracked. - **Raw objects.** Reading through `toRaw(state)` or a plain copy bypasses the proxy, so nothing is registered. The test is always the same: was the value read **through a ref or reactive proxy, during the synchronous run**? ## Practical guidance - Use `watchEffect()` when the side effect naturally reads its own inputs - syncing several values to a chart, a document title, a storage key. - Use `watch()` when you need the old value, laziness, or precise control over which change fires the callback. - Keep reads **synchronous**: only reads made before the first `await` count, so async effects need care. - Do not write to a value you also read and expect a loop; a running effect is not re-triggered by its own writes, which can hide the intent. Derive values with `computed` instead.
- In Vue 3, does watchEffect() re-run if a ref it read is set to the same value?No. A ref only triggers when the new value differs from the old one (compared with Object.is semantics), so assigning the same primitive notifies nobody. Mutating a property of a reactive object to an equal value likewise does not trigger. Replacing an object with a different object that looks identical does trigger, because it is a different reference.
- Why can Vue 3's watchEffect() be cheaper than watch(obj, cb, { deep: true }) on a large object?A deep watch traverses the whole object on every run to register every nested property, then fires on a change anywhere. watchEffect() only registers the properties its function actually reads, so it neither walks the rest of the structure nor re-runs for unrelated changes.
saying these in an interview costs you the question
- watchEffect() needs a dependency array listing what it should watch.
- watchEffect() waits for the first change before running.
- Once a ref has been read, watchEffect() tracks it forever, even if later runs skip it.
- watchEffect() passes the new and old values to its function.
- Every write to a tracked ref re-runs the effect synchronously on the spot.