Skip to the solution
HardFrontend12 min read

What are common reactivity caveats in Vue?

301 views

The problem

Question presented to candidate: "Tell me about the reactivity gotchas you have actually hit in Vue 3. For each one, what is the symptom, why does it happen in terms of how reactivity works, and how do you fix it?"

What a strong answer should cover:

  • Values copied out of the proxy: destructuring a reactive object, passing state.count into a composable, spreading. Fix with toRefs, toRef, getters and toValue.
  • Objects that are never proxied: Date and other built-ins, frozen objects, markRaw, and contents of a shallowRef. Class instances with #private fields throw a TypeError through a Proxy.
  • Reads that are not tracked: after an await in watchEffect or computed, or outside any effect.
  • Identity and unwrapping: the proxy is not === the raw object; refs in reactive arrays and Maps are not unwrapped; watch on a reactive object gives the same object as new and old value.
  • Compile-time and runtime guard rails: passing a destructured prop straight to watch() is a compiler error in 3.5; watchers that write each other's sources end in "Maximum recursive updates exceeded".
  • The Vue 2 caveats (adding keys, arr[i] = x, Vue.set) no longer apply in Vue 3.

Clarifying questions expected:

  • "Composition API with <script setup>, or Options API? Some caveats only exist in one."
  • "Are there large or third-party objects in state? That changes the advice."

Code / implementation expected: Yes โ€” a component where a broken and a fixed version of each caveat render side by side.

  • reactivity-caveats

Try it yourself

Sketch your approach before you read the solution. That is what the interview tests.

Stuck? Get a hint.

A short nudge toward the approach. It will not give the answer away.

Solution

The solution is hidden so you can try first

12 min read ยท 12 sections

Read the code

VueCaveat lab: destructuring, passing a value, and mutating a Date, broken vs fixed
<script setup>
import { reactive, computed, toRef, toRefs, toValue } from "vue";

const state = reactive({ count: 1, when: new Date(Date.UTC(2024, 0, 1)) });

// Caveat 1: destructuring copies the value. toRefs keeps a live link.
const { count: copied } = state;
const { count: linked } = toRefs(state);

// Caveat 2: passing state.count passes the NUMBER. Pass a ref or a getter instead.
function useDouble(n) {
  return computed(() => toValue(n) * 2); // toValue accepts a value, a ref or a getter
}
const doubleFrozen = useDouble(state.count);
const doubleLive = useDouble(toRef(state, "count"));

// Caveat 3: built-ins such as Date are never proxied, so calling a mutating
// method on one is invisible to Vue. Replace the object instead.
function mutateDate() {
  state.when.setUTCFullYear(state.when.getUTCFullYear() + 1);
  console.log("mutated date, now", state.when.getUTCFullYear());
}
function replaceDate() {
  const next = new Date(state.when);
  next.setUTCFullYear(next.getUTCFullYear() + 1);
  state.when = next;
}
</script>

<template>
  <button id="inc" @click="state.count++">count++</button>
  <p id="r1">state.count {{ state.count }} | destructured {{ copied }} | toRefs {{ linked }}</p>
  <p id="r2">useDouble(value) {{ doubleFrozen }} | useDouble(toRef) {{ doubleLive }}</p>
  <button id="mutate" @click="mutateDate">setUTCFullYear()</button>
  <button id="replace" @click="replaceDate">replace Date</button>
  <p id="r3">year shown: {{ state.when.getUTCFullYear() }}</p>
</template>
Eight caveats probed in Node: unwrapping, identity, #private, Date, shallowRef, watch
import { ref, reactive, computed, watch, effect, isReactive, shallowRef, toRef, toValue, nextTick, markRaw, isRef } from "vue";
const tick = () => nextTick();

console.log("1. Vue 2 caveats are gone: new keys, index writes, length all tracked");
const s = reactive({ list: [1, 2], obj: {} });
effect(() => console.log(`   effect: list=${JSON.stringify(s.list)} keys=${Object.keys(s.obj)}`));
s.obj.added = true;       // new property
s.list[5] = 9;            // index beyond length
s.list.length = 1;        // shrink

console.log("2. Passing a property VALUE into a composable freezes it");
function useDouble(n) { return computed(() => toValue(n) * 2); }
const state = reactive({ count: 1 });
const frozen = useDouble(state.count);                 // passes the number 1
const live1 = useDouble(toRef(state, "count"));        // passes a ref
const live2 = useDouble(() => state.count);            // passes a getter
state.count = 10;
console.log(`   value: ${frozen.value} | toRef: ${live1.value} | getter: ${live2.value}`);

console.log("3. Refs inside arrays and Maps are NOT unwrapped");
const r = ref(1);
const arr = reactive([r]);
const map = reactive(new Map([["k", r]]));
console.log(`   arr[0] isRef: ${isRef(arr[0])} | map.get isRef: ${isRef(map.get("k"))} | obj unwraps: ${reactive({ r }).r}`);

console.log("4. Identity: the proxy is not the original");
const raw = { id: 1 };
const list = reactive([raw]);
console.log(`   list[0] === raw: ${list[0] === raw} | list.includes(raw): ${list.includes(raw)} | list.indexOf(raw): ${list.indexOf(raw)}`);
const set = reactive(new Set([raw]));
console.log(`   set.has(raw): ${set.has(raw)}`);

console.log("5. Class with #private fields");
class Counter { #n = 0; inc() { this.#n++; } get n() { return this.#n; } }
const c = reactive(new Counter());
try { c.inc(); console.log("   worked", c.n); } catch (e) { console.log(`   ${e.constructor.name}: ${e.message}`); }
const c2 = reactive({ counter: markRaw(new Counter()) });
c2.counter.inc(); console.log(`   with markRaw: ${c2.counter.n}, isReactive: ${isReactive(c2.counter)}`);

console.log("6. Built-ins like Date are not made reactive");
const d = reactive(new Date(0));
console.log(`   isReactive(date): ${isReactive(d)}`);
const holder = reactive({ when: new Date(0) });
effect(() => console.log(`   effect sees year ${holder.when.getUTCFullYear()}`));
holder.when.setUTCFullYear(2030);
console.log("   (setUTCFullYear did not re-run the effect)");
holder.when = new Date(Date.UTC(2031, 0));

console.log("7. shallowRef only tracks .value replacement");
const sh = shallowRef({ n: 1 });
effect(() => console.log(`   shallow effect n=${sh.value.n}`));
sh.value.n = 2;
console.log("   (nested write ignored)");
sh.value = { n: 3 };

console.log("8. watch(reactiveObject): new and old are the same object");
const form = reactive({ email: "a" });
watch(form, (now, before) => console.log(`   same object: ${now === before}, before.email=${before.email}`));
form.email = "b"; await tick();
watch(() => ({ ...form }), (now, before) => console.log(`   snapshot getter: ${before.email} -> ${now.email}`), { deep: true });
form.email = "c"; await tick();

Discussion

Other approaches, follow-ups and how it went in real interviews.

Sign in to join the discussion.

No comments yet. Share how you would answer it.

Question 87 of 92 in Vue.js

Keep your momentum going.

Mark this question solved to track your progress, then take the next one while it is fresh.

All Vue.js questions
Up nextHard

What is lazy hydration in Vue 3.5 โ€” hydrate strategies (idle, visible, interaction)?

Continue