Frontend / Vue / 03_ref_vs_reactive.md

ref vs reactive vs shallowRef / shallowReactive

Updated 5 min read source
On this page6
  1. TL;DR
  2. In depth
  3. Gotchas / edge cases
  4. What a senior is expected to say
  5. Cross-references
  6. Further reading

ref vs reactive vs shallowRef / shallowReactive

TL;DR

ref wraps any value (primitive or object) in a .value-bearing box; reactive deep-proxies an object directly. Use ref by default — it composes better, survives destructuring (with toRefs), and is uniform across primitive and object values. Reach for reactive only when you specifically want object-style access (state.count not state.count.value). shallow* opt out of deep tracking for performance or correctness with immutable values.

In depth

When ref vs reactive?

ref reactive
Wraps anything objects (incl. arrays, Map, Set)
Access .value direct property access
Reassignable yes — r.value = newObj no — state = newObj breaks tracking
Destructurable template auto-unwraps; toRefs in script loses reactivity on destructure
Type Ref<T> T
Composable returns yes (idiomatic) rarely

Default to ref. Use reactive only when:

  • The shape is fixed and you want state.count ergonomics.
  • You’re working with a Pinia store’s state (Pinia uses reactive internally; you don’t choose).

Why does destructuring lose reactivity?

Reactivity comes from the Proxy intercepting get on the source object. Once you destructure, you have a value that’s no longer routed through the Proxy.

ts
const state = reactive({ count: 0 });
// count is just 0 — no Proxy involved
const { count } = state;
state.count = 5;
console.log(count);                  // still 0

Fix with toRefs:

ts
// count is now Ref<number>
const { count } = toRefs(state);
state.count = 5;
console.log(count.value);           // 5

toRefs returns an object whose values are refs bound to the source’s properties. This is what makes useFoo() composables idiomatic — they return toRefs(state).

Show me a composable returning a clean API.

ts
import { ref, computed, toRefs, reactive } from "vue";

export function useCounter(initial = 0) {
  const count = ref(initial);
  const double = computed(() => count.value * 2);
  const increment = () => { count.value++; };
  return { count, double, increment };
}

// consumer:
const { count, double, increment } = useCounter();

Two equivalent shapes — refs in a returned object (above), or a reactive state + toRefs:

ts
export function useCounter(initial = 0) {
  const state = reactive({ count: initial });
  const double = computed(() => state.count * 2);
  return { ...toRefs(state), double, increment: () => state.count++ };
}

Both compose well. The first is the modern convention.

When use shallowRef?

When the value is immutable from your perspective and you only ever replace the whole thing.

ts
const editor = shallowRef<EditorView | null>(null);   // big object; we don't want it proxied
const list = shallowRef<Item[]>([]);                  // we always replace the list reference

editor.value = createEditor(...);                     // triggers
editor.value.someMethod();                            // does NOT trigger (no deep proxy)
list.value = [...list.value, item];                   // triggers (new reference)
list.value.push(item);                                // does NOT trigger

Use cases:

  • Heavy third-party objects (editors, canvases, ML models) — proxying breaks them or kills perf.
  • Immutable data patterns where you replace not mutate.
  • Performance: large arrays where deep tracking is wasted (you’re replacing not mutating).

When use shallowReactive?

Same idea — only the top-level properties are reactive; nested values are not deeply tracked. You can still reassign nested keys reactively, but mutations to those nested values won’t trigger.

ts
const state = shallowReactive({
  user: { name: "Ada" },
  tab: "home",
});

state.tab = "profile";           // triggers
state.user = { name: "Bob" };    // triggers (top-level)
state.user.name = "Bob";         // does NOT trigger

ref of an object — is the inside reactive?

Yes. ref(obj) internally calls reactive(obj) on the value, so .value.nested = x triggers. If you don’t want that, use shallowRef.

How does the template auto-unwrap refs?

Inside <template> blocks, when you reference a top-level ref, Vue’s compiler emits .value for you:

vue
<script setup>
import { ref } from "vue";
const count = ref(0);
</script>

<template>
  <button @click="count++">{{ count }}</button>
  <!-- compiles to: count.value++ and count.value -->
</template>

This is only at the top level — state.items doesn’t unwrap if items is a ref nested in a reactive. The rule: in templates, top-level refs are auto-unwrapped. In <script>, you always need .value.

What does unref(x) do?

Returns x.value if x is a ref, otherwise x. Useful in composables that accept either:

ts
function useDouble(input: MaybeRef<number>) {
  return computed(() => unref(input) * 2);
}

useDouble(5);            // ok
useDouble(ref(5));       // ok

MaybeRef<T> = T | Ref<T> — a common signature for accepting either.

Object identity — does reactive(x) === x?

No. reactive(x) returns a Proxy; x is the raw target. To get back the raw, use toRaw(proxy). To compare reactive instances, compare them directly (Vue caches: reactive(x) === reactive(x) is true).

Gotchas / edge cases

  • Refs aren’t unwrapped in reactive properties everywhere consistently. A ref placed as a property of a reactive object is unwrapped: state.count where state = reactive({ count: ref(0) }) returns the value, not the ref. But a ref inside an array inside a reactive is not unwrapped. Confusing — pick one pattern.
  • watch on a reactive object without deep: true watches the top reference, not deep changes. Use watch(() => state.count, ...) or deep: true.
  • shallowRef with object mutation silently doesn’t trigger. The most common “why isn’t this updating?” answer when you’re trying to optimize.
  • readonly() creates a read-only Proxy — mutations warn and don’t apply. Good for downstream-only access.
  • isRef/isReactive/isProxy/isReadonly — runtime type checks for these reactive types.
  • triggerRef(shallowRef) lets you manually trigger for a shallow ref after a mutation you know happened — escape hatch.

What a senior is expected to say 5

  • “Default to ref — uniform across primitives and objects, composes well in composables (return refs from useFoo), reassignable without breaking reactivity.”
  • reactive is fine for fixed-shape state with object-style access ergonomics, but destructuring kills it. toRefs is the bridge.”
  • shallowRef for big/immutable values you only replace — editor instances, large arrays you swap not mutate, third-party objects that don’t survive being proxied.”
  • “Top-level refs auto-unwrap in templates. In <script>, you always write .value.”
  • toRaw and markRaw are escape hatches when you need the raw object back or want Vue to leave something alone.”

Cross-references

Further reading