A Vue 3 `watchEffect` reads `details.value` only inside `if (expanded.value)`; while collapsed, changing `details` never re-runs it - why, and is that a bug?
answer
- dependencies re-collected every run
- unread links pruned after the run
- the branch condition is still tracked
- await ends the tracked window
basics
~20 sVue re-collects an effect's dependencies on every run and drops any it did not read. While expanded is false, details is not read, so it is not a dependency. That is correct: the effect still tracks expanded, and re-subscribes to details once the branch runs.
solid answer
~40 sTracking is dynamic. Before each run, Vue 3.5 marks all of the effect's dependency links as unused; every read during the run re-marks its link; after the run, links still unused are removed. So the dependency set is exactly what the **last** run read. While `expanded` is false the `if` body does not execute, `details` is not read, and writing it cannot affect the effect's output - so skipping the re-run is the intended optimisation, not a bug. `expanded` was read, so flipping it re-runs the effect, which then reads and re-subscribes to `details`. It becomes a real bug only when the output depends on a value the run did not read synchronously: reads after an `await`, inside a timer callback, or from a non-reactive copy.
code
ts · 18 linesimport { ref, watchEffect } from 'vue'
const userId = ref(1)
const locale = ref('en')
// Buggy: locale is read after the await, so it is never tracked
watchEffect(async () => {
const res = await fetch(`/api/users/${userId.value}`)
console.log(locale.value, await res.json())
})
// Fixed: read every dependency before the first await
watchEffect(async () => {
const id = userId.value
const lang = locale.value
const res = await fetch(`/api/users/${id}`)
console.log(lang, await res.json())
})go deeper
Recall that Vue only tracks what a run actually reads, so a branch that did not execute adds no dependency.
Explain the per-run cycle: links marked unused before the run, re-marked on read, pruned after, with the branch condition itself still tracked.
Separate the harmless conditional case from real misses - reads after await, in callbacks, from copies - and prove which it is with onTrack and onTrigger.
Set team conventions for async effects and explicit watch sources so tracking gaps are designed out rather than debugged case by case.
## Dependencies belong to a run, not to the code Vue 3 does not analyse an effect's source text. It records dependencies by **observing reads while the function executes** - through proxy `get` traps and ref `.value` getters, each calling `track()` for the active effect. So the question is never "which refs appear in this function" but "which refs did the last run actually read". ## How Vue 3.5 prunes stale dependencies Each dependency relationship is a `Link` between a dep and a subscriber. Around every run of an effect or computed getter: 1. **Before the run**, `prepareDeps()` sets every existing link's version to `-1`, meaning "not read yet". 2. **During the run**, each `track()` call either creates a new link or finds the existing one and syncs its version to the dep's current version. 3. **After the run**, `cleanupDeps()` walks the links and removes any still at `-1` from both lists - the dep stops notifying this effect. The set is therefore rebuilt on every run, cheaply, by reusing links rather than recreating them. ## Walking the scenario ```ts watchEffect(() => { if (expanded.value) { render(details.value) } }) ``` | Run | `expanded` | Reads | Dependencies after run | |---|---|---|---| | 1 | `false` | `expanded` | `expanded` | | write `details` | - | - | no re-run: not a dependency | | 2 (after `expanded = true`) | `true` | `expanded`, `details` | `expanded`, `details` | | 3 (after `expanded = false`) | `false` | `expanded` | `expanded` (`details` pruned) | The effect's output while collapsed cannot depend on `details`, so ignoring it is correct and saves work. The same logic makes `a.value && b.value` stop tracking `b` while `a` is falsy. ## When dynamic tracking really is the bug The symptom "it doesn't re-run when X changes" is a real defect when the output **does** depend on X but X was not read synchronously inside the tracked window: - **After an `await`.** The docs are explicit: `watchEffect` only tracks during its synchronous execution; with an async callback, only reads before the first `await` are tracked. Once the function suspends, the active effect is restored to whatever was running before. - **Inside a callback.** A read in `setTimeout`, a promise `then` or an event listener runs later, with no active effect. - **From a copy.** A value copied out of reactive state earlier (a destructured primitive, a cached plain object) has no accessor to track. The fix is to read the dependency synchronously before the first `await` or callback, or to use `watch` with an explicit source when the dependency list must be fixed. ## Diagnosing it - Pass `onTrack` and `onTrigger` to `watchEffect`, `watch` or `computed` (development builds only): `onTrack` fires for each dependency recorded, `onTrigger` for the write that caused a re-run. - For a component render, `onRenderTracked` and `onRenderTriggered` do the same (also development only). - Put a `debugger` in `onTrack` and check which reads were recorded on the last run - a missing entry pinpoints the untracked read. ## What to say in an interview - Dependencies are **per run** and pruned after each run. - A branch not taken is not a missed dependency; its condition is tracked, and taking it re-subscribes. - Real misses come from reads outside the synchronous run, not from conditionals. ## The same mechanism in templates and computeds The render of a component is an effect too, so a template's `v-if` branch behaves the same way: while `v-if="expanded"` is false, the refs used only inside that block are not dependencies of the component's render, and writing them re-renders nothing. A `computed` getter follows the same per-run cycle; a computed like `ready.value ? total.value : 0` depends on `total` only while `ready` is true. None of this needs handling in application code - it is what keeps Vue's updates proportional to what the screen actually shows.
- How do you find out which reads a Vue 3 effect actually tracked on its last run?In a development build pass `onTrack` and `onTrigger` to `watchEffect`, `watch` or `computed`; for a component's render use `onRenderTracked` and `onRenderTriggered`. Each `onTrack` call reports one recorded dependency, so a value that never appears was not read in the tracked window.
- Why doesn't Vue simply keep every dependency an effect has ever read, to be safe?Stale links would re-run effects for values that no longer influence their output, wasting work and keeping objects alive. Pruning after each run keeps the set exact, and the tracked branch condition guarantees that a skipped branch re-subscribes as soon as it matters.
saying these in an interview costs you the question
- Vue scans the effect's function body to find its dependencies ahead of time.
- Once an effect reads a ref, it stays subscribed until the component unmounts.
- Conditional reads are a flaw in Vue's tracking and must always be hoisted.
- An async watchEffect tracks reads that happen after an await.
- A missing dependency is fixed by listing it in a dependency array.