Reactivity in Depth: ref vs reactive, toRefs and shallowRef

Intermediate
13 min

Reactivity in Depth: ref vs reactive, toRefs and shallowRef

You have used ref and reactive to declare state. This lesson explains how Vue tracks changes, why a ref needs .value, when reactive silently stops working, and which utilities (toRefs, shallowRef, readonly, toRaw) solve real problems. Understanding these rules is what separates code that "usually works" from code that is reliably reactive.

How Vue Tracks Changes

Vue 3 reactivity is built on JavaScript Proxy. When you call reactive(obj), Vue returns a proxy that intercepts every property read and write. During rendering, or while a computed or watch runs, each property read is recorded as a dependency of that effect. A later write to the same property re-runs exactly the effects that depend on it, and nothing else.

Primitives (numbers, strings, booleans) cannot be proxied, which is why ref exists: it wraps the value in an object with a reactive .value property. The proxy is not the original object: reactive(obj) === obj is false.

ref vs reactive

| | ref() | reactive() | | --- | --- | --- | | Accepts | Any value | Objects, arrays, Map, Set | | Access in script | .value | Direct property access | | Access in template | Auto-unwrapped | Direct | | Replace the whole value | state.value = newObj works | Reassigning breaks the link | | Destructuring | Keeps the ref intact | Loses reactivity |

ref holding an object converts that object with reactive internally, so user.value.name = 'Ada' is tracked. The official recommendation is to use ref as the default and reach for reactive only when you want a group of related fields without .value.

Losing Reactivity: The Two Classic Cases

javascript
import { reactive, toRefs, toRef } from 'vue' const state = reactive({ count: 0, label: 'clicks' }) // 1. Destructuring copies plain values let { count } = state count++ // state.count is still 0 // 2. Reassignment replaces the proxy with a plain object // state = reactive({ count: 5 }) // other code still holds the old proxy // Fix: convert properties to refs that stay linked to the proxy const { count: countRef, label } = toRefs(state) countRef.value++ // state.count is now 1 const single = toRef(state, 'label')

toRefs is exactly what makes composables returning reactive objects usable with destructuring: const { x, y } = useMouse(). Passing a reactive property into a function (format(state.count)) also passes a plain number; pass the whole object or a ref instead.

Shallow Reactivity for Performance

Deep conversion has a cost. For large arrays or objects that you replace wholesale rather than mutate, use shallowRef, which only tracks .value assignment:

javascript
import { shallowRef, triggerRef, markRaw } from 'vue' const rows = shallowRef([]) async function load() { rows.value = await fetch('/api/rows').then(r => r.json()) // triggers update } rows.value.push({ id: 99 }) // NOT tracked; the array is not proxied triggerRef(rows) // force dependents to re-run if you must mutate const chart = markRaw(new ChartLibrary()) // never make this reactive

shallowReactive does the same for objects: only top-level properties are reactive. markRaw tells Vue to skip an object entirely, the right choice for third-party class instances such as chart or map objects.

readonly, toRaw and Other Utilities

javascript
import { reactive, readonly, toRaw, toValue, isRef } from 'vue' const state = reactive({ theme: 'dark' }) const frozen = readonly(state) frozen.theme = 'light' // warning in dev, no change toRaw(state) === state // false: returns the original plain object toValue(() => 5) // 5 (calls getters; unwraps refs; passes plain values) isRef(state) // false
  • readonly(obj) returns a deep read-only proxy. Use it when you provide state and want children to call functions instead of mutating.
  • toRaw(proxy) returns the original object, useful when a library needs a plain object.
  • unref(x) returns x.value if x is a ref, otherwise x; toValue(x) also calls getters. Both let composables accept refs, getters or plain values.
  • customRef builds a ref with custom get/set, which is how debounced inputs are implemented.

Common mistakes

  • Reading count instead of count.value in script; the ref object is always truthy, so if (count) is a silent bug.
  • Expecting refs inside a reactive array to unwrap: refs are only unwrapped as properties of reactive objects, not as array or Map elements.
Quick Quiz
Question 1 of 2

Why does `const { count } = reactive({ count: 0 })` lose reactivity?

Key Takeaways

  • Vue 3 reactivity uses Proxy; ref wraps primitives in an object with a reactive .value.
  • Prefer ref by default; reactive is for objects and breaks on destructuring or reassignment.
  • toRefs and toRef convert reactive properties into linked refs.
  • shallowRef, shallowReactive and markRaw reduce the cost of large or third-party objects.
  • readonly, toRaw, unref and toValue cover the remaining edge cases.

Next lesson: watch, watchEffect and Side-Effect Cleanup — run code when state changes, control timing, and clean up stale requests.

Reactivity in Depth: ref vs reactive, toRefs and shallowRef - Vue.js | CodeYourCraft | CodeYourCraft