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.countergonomics. - You’re working with a Pinia store’s
state(Pinia usesreactiveinternally; 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
reactiveproperties everywhere consistently. A ref placed as a property of areactiveobject is unwrapped:state.countwherestate = 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. watchon areactiveobject withoutdeep: truewatches the top reference, not deep changes. Usewatch(() => state.count, ...)ordeep: true.shallowRefwith 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 fromuseFoo), reassignable without breaking reactivity.” - “
reactiveis fine for fixed-shape state with object-style access ergonomics, but destructuring kills it.toRefsis the bridge.” - “
shallowReffor 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.” - “
toRawandmarkRaware escape hatches when you need the raw object back or want Vue to leave something alone.”
Cross-references
- The Proxy machinery behind these: 02_reactivity_internals.md
- Watching reactive things: 04_computed_watch_watcheffect.md
- Performance —
markRaw/shallowRefuse cases: 13_performance.md
Further reading
- Vue docs — Reactivity Fundamentals: https://vuejs.org/guide/essentials/reactivity-fundamentals.html
- Vue docs — Reactivity API: Core: https://vuejs.org/api/reactivity-core.html
- Anthony Fu — “Mastering Vue 3 Reactivity”: https://antfu.me/posts/mastering-vue-reactivity