Frontend / Vue / 03_ref_vs_reactive.md

ref vs reactive vs shallowRef / shallowReactive

Updated 4 interview angles 5 min read source
On this page13
  1. When ref vs reactive?
  2. Why does destructuring lose reactivity?
  3. Show me a composable returning a clean API.
  4. When use shallowRef?
  5. When use shallowReactive?
  6. ref of an object — is the inside reactive?
  7. How does the template auto-unwrap refs?
  8. What does unref(x) do?
  9. Object identity — does reactive(x) === x?
  10. Gotchas
  11. Related
  12. Further reading
  13. Interview angle

ref vs reactive vs shallowRef / shallowReactive

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.

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

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

Further reading

Interview angle 4

  • “ref or reactive?” - default to ref. It works uniformly for primitives and objects, it is reassignable without breaking the link, and it composes properly in composables - a useFoo returns refs and the caller can destructure them safely.
    • Follow-up: “So when is reactive right?” - fixed-shape state where the object-style access reads better and you never reassign the whole thing. The moment someone destructures it, the reactivity is gone, and toRefs is the bridge you then need.
  • “When would you use shallowRef?” - for a large or externally-owned value you replace rather than mutate: an editor instance, a big array you swap wholesale, a third-party object that misbehaves when proxied. Deep proxying it costs real time and buys nothing.
  • “Why do you write .value in script but not in the template?” - templates auto-unwrap top-level refs as a convenience. In <script> there is no compiler step doing that for you, so the .value is always explicit - and forgetting it is the most common Vue 3 mistake.
  • “What are toRaw and markRaw for?” - getting the underlying object back out of a proxy, and telling Vue never to proxy it in the first place. Both are escape hatches; needing them regularly usually means state is in the wrong shape.