skip to content

In Vue 3, when does watchEffect() run its function, and which reactive reads become its dependencies?

level: juniorimportance: must knowfreq 62%

answer

  1. no source argument
  2. runs once straight away
  3. whatever it read last time
  4. branches change the set

basics

~20 s

With 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 lines
ts
import { 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 points

go deeper

for a junior

Know that watchEffect() runs at once and re-runs when anything it read changes, with no source list and no old value.

for a middle

Explain that dependencies are re-collected each run, how conditional reads add and drop dependencies, and how that differs from watch().

for a senior

Choose between watchEffect(), watch() and computed by what the side effect needs, and use conditional tracking deliberately rather than by accident.

for a principal

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.