frontend / vue / 03_ref_vs_reactive.md

ref vs reactive vs shallowRef / shallowReactive

5 min read source

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.

Interview Q&A

Q: When ref vs reactive?

A:

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

Q: Why does destructuring lose reactivity?

A: 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.

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

Fix with toRefs:

const { count } = toRefs(state);    // count is now Ref<number>
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).

Q: Show me a composable returning a clean API.

A:

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:

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.

Q: When use shallowRef?

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

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

Q: When use shallowReactive?

A: 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.

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

Q: ref of an object — is the inside reactive?

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

Q: How does the template auto-unwrap refs?

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

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

Q: What does unref(x) do?

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

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.

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

A: 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

  • “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