Skip to content
beginner Phase 3 · Reactive Refs and the Composition API

ref for Primitive State

Create reactive scalar values and read them with the value property.

45m
0 problems
Topic Progress 0%

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:

  1. In script, always .value. count.value++, count.value = 5, console.log(count.value). Missing .value on 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 = 1 fails to type-check), but plain-JS projects feel it as "the UI never updates".
  2. In templates, top-level refs unwrap. {{ count }} reads count.value automatically; @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.
  3. Unwrapping has edges. A ref nested inside a reactive object unwraps on access (state.count gives 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.
  4. Refs are interchangeable dependencies. Computed, watch, and every render effect that reads count.value register 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.is equal) — 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 — raw is the unwrapped inner object; the write bypasses the reactive proxy, nothing re-renders. Always mutate s.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 through watch/onInterval composables instead.
  • Storing the ref's value in a local: const local = count.value snapshots 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.value destructures a snapshot; const raw = user.value then raw.name = 'x' writes to the unreactive original. Passing user.value.name into a function passes a string; passing user passes the live container.
  • Replacement is a normal write with normal semantics. user.value = response.data triggers 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. shallowRef skips deep conversion (only .value replacement 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?

Question 1 options

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?

Question 2 options

3. `const user = ref({ address: { city: 'Lisbon' } })`. Does `user.value.address.city = 'Porto'` trigger updates?

Question 3 options

4. You pass `count` (a Ref) to a plain helper function. What must the helper do, and why doesn't auto-unwrap apply?

Question 4 options

Flashcards

Question

What is a ref, and why must primitives be wrapped?

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?

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?

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?

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

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.

Continue Learning

Before You Start

Next Topics

Related Reading