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?
answer
- JavaScript cannot intercept local variables
- property access can be intercepted
- getter tracks, setter triggers
- only while an effect is running
basics
~20 sVue 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 sVue 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<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
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.
Explain the active effect: track() records whichever effect is running, so reads in a render subscribe while reads in a click handler do not.
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.
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.