The Wrapper That Makes Primitives Trackable: Reading ref's Contract
A counter component needs one piece of state: how many times the button was pressed. In script setup it is one line — const count = ref(0) — and the interesting part is everything that line implies.
<script setup lang="ts">
import { ref } from 'vue'
const count = ref(0) // Ref<number>, internal value 0
function increment() {
count.value++ // write goes through the ref's setter
}
</script>
<template>
<button @click="increment">{{ count }}</button> <!-- reads without .value -->
</template>
ref returns a small object — a container — with a single reactive property, value. In TypeScript it is Ref<number>, and the reason it exists at all is mechanical: JavaScript Proxies intercept operations on objects, and a number or string is not an object, so there is nothing to intercept. Wrapping the primitive in an object gives the reactivity system a place to hook reads (track "who asked for my value") and writes (notify everyone who asked). The .value accessor you type in script is the visible cost of that design; the automatic unwrapping you saw in the template is the ergonomics Vue pays back.
The complete contract in four rules:
- In script, always
.value.count.value++,count.value = 5,console.log(count.value). Missing.valueon write silently creates a new plain property on the ref object instead of changing state — a bug TypeScript usually catches (const x: Ref<number> = ...; x = 1fails to type-check), but plain-JS projects feel it as "the UI never updates". - In templates, top-level refs unwrap.
{{ count }}readscount.valueautomatically;@click="count++"writes it. This applies to refs exposed by setup (the script setup top-level bindings) — the compiler knows they are refs and rewrites the access. - Unwrapping has edges. A ref nested inside a
reactiveobject unwraps on access (state.countgives the raw number); a ref inside a plain array or Map does not (arr[0]is the ref object). We will return to this in the pitfalls topic — it is the source of most "why is my value a RefImpl" console logs. - Refs are interchangeable dependencies. Computed, watch, and every render effect that reads
count.valueregister against the same container, so one assignment updates templates, watchers, and derived computeds together.
Why not a plain variable plus manual notifications? That is exactly what ref automates — track on read, notify on write — and the next topic's reactive is the same machinery applied to objects where the wrapper can be a Proxy directly. The type signature also earns its keep: ref<number> refuses count.value = 'ten' at compile time, and Ref<T | null> plus {{ x ?? 'loading' }} is the idiomatic typed pair for async data.
One habit worth forming immediately: name the state for what it is, not for the mechanism. const searchQuery = ref('') and const isSaving = ref(false) read as domain facts; const searchQueryValue = ref('') adds noise. And when a function needs to manipulate state, pass the ref itself (function reset(counter: Ref<number>) { counter.value = 0 }) rather than its value — that keeps the update reactive, which is the subject of the next chapter.
What an Assignment Actually Triggers — And When It Doesn't
Every template that reads count re-renders when count.value changes. That sentence hides three precise behaviors: when the trigger fires, who it reaches, and the notable case where it does not fire at all.
Writes trigger by comparison, not by occurrence. The ref's setter compares old and new values with Object.is. count.value = 0 when it is already 0 triggers nothing — no re-render, no watcher callback. count.value = 1 triggers every dependent. For primitives this is pure win (accidental identical writes are no-ops); for object values the comparison is by reference, so user.value = { name: 'Ana' } always triggers even when the contents are identical — one reason to prefer mutating through a reactive object or using watch's deep selectively rather than re-assigning structurally equal objects.
Effects are batched per tick. Ten increments in one event handler do not cause ten renders:
function addTen() {
count.value++ // × 10 in the same synchronous block
}
Each write marks the render effect dirty; Vue schedules it on a microtask queue and runs it once with the final value. The DOM sees one update — which is also why logging count.value inside a loop shows every intermediate value while the template only ever paints the last one.
Who gets reached: every effect that read count.value during its last run. Three flavors matter in practice:
const count = ref(0)
const double = computed(() => count.value * 2) // derived ref, recomputes on trigger
watch(count, (n) => console.log('was', n)) // callback runs after the change
function addToCart(item) { // template render effects re-run
cart.value.push(item) // when their render read cart
}
computed is itself a ref — reading double.value tracks it like any other, and its lazy chain means one assignment recomputes each derived value exactly once per flush, never once per read. That laziness is the practical answer to "should I use a function or a computed in the template": a {{ items.filter(f).length }} recomputes per read, a computed caches until its inputs change.
The non-triggering cases are worth memorizing as a checklist for "my UI doesn't update":
- Assigning the same primitive (
Object.isequal) — no trigger. - Mutating a plain object held by a ref without going through it:
const s = ref({ a: 1 }); const raw = s.value; raw.a = 2—rawis the unwrapped inner object; the write bypasses the reactive proxy, nothing re-renders. Always mutates.value.a = 2. - Reading state outside any tracked context:
console.log(count.value)in a timer logs fine but registers no dependency — correct, but it means the timer will not re-run; wire timers throughwatch/onIntervalcomposables instead. - Storing the ref's value in a local:
const local = count.valuesnapshots the number forever; the binding was cut at that instant.
Each of those is the same failure shape: a path that bypasses the container's getter or setter. The reactive system sees reads and writes that pass through it, and only those. Internalize that and half of reactivity debugging reduces to tracing which reference the buggy code actually holds — the full machinery behind it is dissected in How Vue Tracks Reactivity.
ref Works for Objects Too — Deeply, Until You Ask Otherwise
ref is documented as "the ref for primitives", and yet ref({ user: null }) is everywhere in real code. The name is historical: the container is the same, and when its initial value is an object, Vue wraps it in a reactive Proxy deeply — every nested object becomes a proxy as you access it.
import { ref } from 'vue'
const user = ref({ name: 'Ana', address: { city: 'Lisbon' } })
user.value.name = 'Bruno' // triggers: nested through the proxy chain
user.value.address.city = 'Porto' // triggers too — deep conversion on access
user.value = { name: 'Clara', address: { city: 'Madrid' } } // replace: also fine
The three lines above are why many teams standardize on ref for everything. reactive (next topic) cannot be reassigned wholesale — the variable is the proxy, so replacing the object means mutating properties one by one. A ref can always take a new value: user.value = freshPayload after a fetch, with one trigger and a clean diff.
What "deeply" costs, and where the trade-offs live:
- Conversion is lazy and cached. The proxy wraps a child object the first time you read it, then hands back the same proxy on later reads. A state tree that is never touched never pays conversion — but a 50,000-row array you do touch is fully wrapped.
- Everything you keep must stay behind
.value.const { name } = user.valuedestructures a snapshot;const raw = user.valuethenraw.name = 'x'writes to the unreactive original. Passinguser.value.nameinto a function passes a string; passinguserpasses the live container. - Replacement is a normal write with normal semantics.
user.value = response.datatriggers effects even if contents are identical (reference comparison) — for data fetched fresh from a server that is usually what you want; when it isn't,watch(user, ..., { deep: true })or targeted property writes narrow the churn. - Escape hatches exist for hostile shapes.
shallowRefskips deep conversion (only.valuereplacement triggers — ideal for huge immutable datasets, third-party chart objects, or virtual-list windows);toRaw(user.value)returns the underlying object for libraries that must not see proxies (canvas engines, some serialization paths);markRaw(obj)opts an object out of conversion permanently — the standard fix for a component instance or class instance stored in state that would otherwise be pointlessly proxied.
Reading a ref back out has its own rules — isRef, unref(x) as x.value ?? x shorthand, and template unwrapping — but the operation worth committing to memory is the object lifecycle: create with a default shape (ref({ items: [] }), never ref(null) plus scattered null checks), mutate through .value, replace wholesale on fetch, keep third-party objects shallow or marked.
That completes the primitive-side picture: a container whose getter and setter are the only gateways state has to the reactive system. reactive moves that gateway from a wrapper object to a Proxy over your own object — with all the freedom and the sharp edges that implies — in reactive for Objects.
Quiz
1. What does `const count = ref(0)` return, and why does the design need a wrapper at all?
2. state.value is 5, and you run `state.value = 5` followed by `state.value = 5` again after other writes returned it to 5. What happens?
3. `const user = ref({ address: { city: 'Lisbon' } })`. Does `user.value.address.city = 'Porto'` trigger updates?
4. You pass `count` (a Ref) to a plain helper function. What must the helper do, and why doesn't auto-unwrap apply?
Flashcards
Question
What is a ref, and why must primitives be wrapped?
Click to reveal answer
Answer
ref(value) returns a Ref container whose .value is the state. Proxies intercept operations on objects only, so primitives (numbers, strings, booleans) can't be tracked directly — the wrapper object provides the getter (record dependency) and setter (trigger effects) hooks. In templates, top-level refs unwrap automatically; in script, .value is always required.
Question
When does an assignment to ref.value trigger updates, and when doesn't it?
Click to reveal answer
Answer
Triggers when Object.is(old, new) differs — reaching every render effect, computed, and watcher that read it; effects batch to one render per tick. No trigger on same-value writes (silent no-op), on mutations of the raw inner object (bypassing the proxy), or on reads outside tracked contexts. Object values compare by reference, so re-assigning structurally equal objects still triggers.
Question
Where does ref unwrapping happen and where does it not?
Click to reveal answer
Answer
Happens: top-level setup bindings in templates ({{ count }} reads count.value) and refs nested inside reactive objects (state.count). Does NOT happen: plain function parameters, refs inside plain arrays/Maps, or local variables holding refs — those are containers you must read via .value (or unref()).
Question
How does ref handle object values?
Click to reveal answer
Answer
It deep-converts: the object is wrapped in a reactive Proxy, and nested objects are wrapped lazily on first access and cached. So deep mutations (user.value.address.city = x) trigger, replacement (user.value = fresh) triggers, and the escape hatches are shallowRef (no deep wrapping, replace-only triggering), toRaw (underlying object), and markRaw (opt out permanently).
Question
Give the checklist for "my ref updated but the UI didn't".
Click to reveal answer
Answer
1) Wrote without .value (created a stray property on the container). 2) Assigned an Object.is-equal value (no-op by design). 3) Mutated the raw inner object (const raw = user.value; raw.x = 1 bypasses the proxy). 4) Snapshotted earlier (const local = count.value). 5) The write happened in a context whose effect was already disposed. All are paths that skip the container's getter/setter.
Revision Notes
Key Takeaways
- 1. ref wraps state in a container because Proxies can't intercept primitives; .value is the tracking gateway, and templates unwrap top-level refs automatically
- 2. Triggers are equality-gated (Object.is) and batched per tick — same-value writes are silent no-ops and ten increments paint once
- 3. Object values are deeply proxied on access, so ref covers both scalar and object state; replacement is just another write
- 4. Bypassing the container — raw object mutation, snapshot variables, missing .value — is the single failure shape behind most 'UI not updating' bugs
- 5. Escape hatches scale the cost: shallowRef for giant data, markRaw for class instances, toRaw for libraries that must not see proxies
Interview Tips
- • "Why does ref need .value?" — primitives can't be Proxy targets; the wrapper object supplies getter/setter hooks for track/trigger; note the template unwrapping that hides it in markup
- • "What triggers a re-render?" — Object.is-different writes, batched per tick; name the no-trigger cases (equal primitive, raw mutation, snapshots) to show the whole model
- • "ref or reactive for objects?" — ref allows wholesale replacement and one type for all state; reactive allows no replacement and destructuring loses reactivity; many teams default to ref — know both sides
- • "Large immutable dataset in state — what do you do?" — shallowRef (replacement-only triggering avoids wrapping 50k rows), plus toRaw/markRaw for third-party objects — proves you know the performance knobs
Cheat Sheet
ref for Primitive State
Contract:
const count = ref(0) // Ref<number>
count.value++ // script: always .value
const double = computed(() => count.value * 2)
watch(count, n => ...) // fires after change
// template: {{ count }} reads count.value automatically
Trigger semantics:
| Write | Triggers? |
|---|---|
count.value = 1 (different) |
yes — batched to one render per tick |
count.value = 0 when already 0 |
no — Object.is gate |
user.value = {...} new reference |
yes (reference comparison) |
raw.x = 1 on the unwrapped object |
no — bypasses the proxy |
| same-value object re-assign | yes — compare by reference |
Objects in refs: deeply converted (lazily, cached proxies) → deep mutations trigger · replace wholesale after fetch · init with a real default ({ items: [] }) · keep third-party/class instances out via markRaw · shallowRef for huge or immutable data (only replacement triggers) · toRaw to hand the plain object out.
Unwrapping map: templates (top-level setup refs) ✔ · inside reactive objects ✔ · plain function params ✗ · inside plain arrays/Maps ✗ · local variables ✗.
Bug checklist ("UI didn't update"): missing .value · same-value write · mutation of raw object · snapshot variable · disposed effect.