skip to content

Dependency Tracking Mechanics

Proxy get traps call track() and set traps call trigger(), linking each key's dep to the running effect; computed is a lazy effect. Interviewers ask how Vue knows exactly what to re-run.

part ofVue.jsoverview, primer and where to startread it →
on this pageshow

explore

questions

5

In a Vue 3 `<script setup>` component, why does `count++` on a plain `let count = 0` not update the template, while `count.value++` on a `ref` does?

level: juniorimportance: must knowfreq 70%

answer

  1. JavaScript cannot intercept local variables
  2. property access can be intercepted
  3. getter tracks, setter triggers
  4. only while an effect is running

basics

~20 s

Vue can only see reads and writes that pass through a property access it controls. A ref's .value getter calls track() and its setter calls trigger(), so the render effect subscribes and re-runs; a plain variable's reads and writes are invisible to Vue.

solid answer

~40 s

Vue 3's reactivity is runtime dependency tracking, and JavaScript offers no hook on a local variable - only on object property access, through getters/setters or a Proxy. A `ref` is an object whose `value` accessor does the work: the getter calls `track()`, which records the currently running effect (for a template, the component's render effect), and the setter calls `trigger()` when the new value differs, which schedules every recorded effect. `let count = 0` has no accessor, so `count++` changes memory Vue never hears about. The template may still show the new number later, but only if something else makes the component re-render.

code

vue · 18 lines
vue
<script setup lang="ts">
import { ref } from 'vue'

let plain = 0
const tracked = ref(0)

function bumpPlain() {
  plain++ // no trigger: the screen keeps the old number
}
function bumpTracked() {
  tracked.value++ // setter calls trigger(): re-render scheduled
}
</script>

<template>
  <button @click="bumpPlain">plain: {{ plain }}</button>
  <button @click="bumpTracked">tracked: {{ tracked }}</button>
</template>

go deeper

for a junior

Recall that only ref() and reactive() values are reactive, and that a ref's .value getter and setter are where Vue notices reads and writes.

for a middle

Explain the active effect: track() records whichever effect is running, so reads in a render subscribe while reads in a click handler do not.

for a senior

Recognise the 'updates late' symptom of a plain variable being re-read by an unrelated re-render, and fix the source instead of forcing updates.

for a principal

Frame why Vue chose explicit ref wrappers over compiler magic, citing the removed Reactivity Transform, and what that means for code-review rules.

## The short version Vue 3 does not diff state and does not re-render on a timer. It re-runs a component's render only when a value that render **read** is later **written**. Both halves need an interception point: Vue must notice the read (to subscribe) and the write (to notify). A `ref` gives Vue both points; a plain `let` variable gives it neither. ## Why a plain variable is invisible JavaScript has no mechanism to observe reads or writes of a local binding. `count++` on a `let` compiles to a plain load and store; no user code runs. What JavaScript *can* intercept is **property access on an object**: - an accessor property (`get value() {}` / `set value(v) {}`) runs code on every read and write; - a `Proxy` runs its `get` and `set` traps for every property of the wrapped object. Vue 3 uses the first for `ref()` and the second for `reactive()`. Anything that is not reached through one of those is not reactive, however it is declared. ## What a ref adds: the `.value` accessor Inside Vue's source, a ref is a small class instance holding the value and its own **dep** (the subscriber list for that ref): ```ts // simplified from packages/reactivity/src/ref.ts class RefImpl<T> { dep = new Dep() constructor(private _value: T) {} get value() { this.dep.track() // subscribe whoever is running return this._value } set value(v: T) { if (!Object.is(v, this._value)) { this._value = v this.dep.trigger() // notify the subscribers } } } ``` So `count.value++` is a getter call followed by a setter call. The getter lets Vue subscribe the reader; the setter lets Vue notify it. Writing the same value again does nothing, because the setter compares with `Object.is` first. ## The active effect decides whether a read counts `track()` does not subscribe "the component" in the abstract. It subscribes the **active effect** - the function Vue is running at that moment with tracking enabled: 1. When a component mounts, Vue wraps its render in a reactive effect and runs it; that effect becomes active. 2. Every `.value` read during that render calls `track()`, which links the ref's dep to the render effect. 3. A later `count.value++` calls `trigger()`, which asks the scheduler to re-run the linked render effect. Reads made when no effect is running - in a click handler, in a `setTimeout` callback - reach `track()` too, but find no active effect and record nothing. That is why reading `.value` in an event handler does not subscribe anything, and why that is fine. ## Where the plain variable still shows up A `let` binding in `<script setup>` is still usable in the template; the compiled render function reads its current value whenever it runs. That produces a confusing symptom: | Declaration | Write | Re-render scheduled? | What the screen shows | |---|---|---|---| | `let count = 0` | `count++` | no | old number until some other tracked change re-renders the component | | `const count = ref(0)` | `count.value++` | yes | new number after the update flush | Developers sometimes conclude the variable "updates late" or "randomly". In fact it never triggers anything; it is merely re-read when an unrelated ref causes a render. ## Common mistakes - Expecting the compiler to turn `let` into reactive state. Vue 3.3 deprecated and 3.4 removed the experimental Reactivity Transform that did something like this; today only explicit `ref()`/`reactive()` values are reactive. - Believing any read of `.value` subscribes the component. Only reads made while an effect is running are tracked. - Thinking Vue compares whole state trees after events. It notifies only the effects that read the written value. - Assuming a plain object is safe because it is `const`. `const settings = { dark: false }` is exactly as invisible as a `let`: mutating `settings.dark` runs no trap, because the object was never passed to `reactive()`. ## The same rule for `reactive()` `reactive(obj)` applies the idea to a whole object. It returns a Proxy whose `get` trap calls `track()` for the property read and whose `set` trap calls `trigger()` for the property written. Two consequences follow directly from "reactivity lives in property access": - a write that goes **through the proxy** is seen; a write to the **original object** behind it is not, because no trap runs; - a value copied **out of** the proxy into a local variable is a plain value again, and later reads of that variable are untracked. The rule to carry into an interview: **reactivity lives in property access**. A ref exists to turn a single value into a property access Vue controls.

  • Does reading `count.value` inside a Vue click handler subscribe the component to that ref?
    No. `track()` only records a dependency when an effect is running with tracking enabled. A click handler runs outside any effect, so the read returns the value and records nothing. Only reads during a render, a computed getter or a watcher's tracked phase subscribe.
  • Why can't Vue intercept `count = 5` the way it intercepts `state.count = 5`?
    Because JavaScript exposes no hook for assignments to a local binding. Hooks exist only for object property access: accessor properties and Proxy traps. Vue 3 therefore wraps single values in a ref object and whole objects in a Proxy, so every tracked read and write is a property access.

A ref is a shop door with a bell: every visit (a read) and every delivery (a write) rings it, so Vue can note who came in while it is taking the register and tell them when a delivery arrives. A plain variable is an open window - things pass through, but no bell ever rings.

saying these in an interview costs you the question

  • Vue watches every script setup variable and re-renders on any change.
  • The compiler turns every let binding into a ref automatically.
  • Any read of .value, even in an event handler, subscribes the component.
  • Vue periodically compares old and new state to find what changed.
  • .value is a naming convention with no runtime behaviour behind it.
open as a page

In Vue 3, how does the reactivity system know exactly which effects to re-run when one property of a `reactive()` object changes?

level: middleimportance: must knowfreq 58%

basics

~20 s

The proxy's get trap calls track(target, key), linking the running effect to a dep stored under targetMap, then target, then key. The set trap calls trigger(target, key) only when the value changed or the key is new, notifying just that key's subscribers.

open as a page

In Vue 3.5, what does it mean that `computed()` is a lazy effect, and how does it avoid needless recomputation and re-renders?

level: middleimportance: should knowfreq 45%

basics

~20 s

A computed never runs its getter when a source changes; it only marks itself dirty and notifies its readers. It recomputes on the next .value read, and bumps its own version only if the result changed, so readers whose inputs are unchanged skip re-rendering.

open as a page

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?

level: seniorimportance: should knowfreq 40%

basics

~20 s

Vue 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.

open as a page

How would you build a debounced ref with Vue 3's `customRef`, and what do the `track` and `trigger` arguments of its factory do?

level: middleimportance: nice to knowfreq 25%

basics

~20 s

customRef takes a factory that receives track and trigger and returns get and set. Call track() in get so readers subscribe; in set, restart a timer and only when it fires store the value and call trigger(), so readers update once typing pauses.

open as a page