"Why does my component not update" is the most common Vue debugging session there is, and the answer is almost always one of six reactivity rules. Interviewers ask about ref versus reactive, about watch versus watchEffect, and about when Vue flushes, because those three answers reveal whether you understand the dependency graph or have memorised the docs.
ref versus reactive. ref holds any value behind .value and can be reassigned; reactive proxies an object in place and cannot be reassigned or destructured without losing tracking. The community default is ref for everything, reactive for a grouped object you never replace. In templates and inside reactive objects, refs are unwrapped automatically; in arrays and Maps they are not.ref and reactive track nested properties recursively. shallowRef and shallowReactive track only the top level, which is the right tool for large arrays, third-party instances and anything you replace wholesale rather than mutate. markRaw opts an object out completely; toRaw reads the original.computed is lazy and cached. It recomputes only when a dependency changed and something reads it. It must be pure: side effects in a getter run at unpredictable times. Writable computeds exist (get/set) but usually signal a design problem. In 3.4+ a computed that produces the same value does not trigger dependents.watch versus watchEffect. watch takes an explicit source (a ref, a getter, an array) and gives you old and new values, lazily by default; watchEffect tracks whatever it reads and runs immediately. Use watch for "do X when Y changes", watchEffect for "keep this side effect in sync". Both return a stop handle and accept onCleanup (3.5 adds the standalone onWatcherCleanup).flush: 'pre' (before the component re-renders). flush: 'post' runs after the DOM update (watchPostEffect); flush: 'sync' runs on every mutation and is almost always a mistake. A deep: true watcher on a large object is a hidden performance cost; prefer watching the specific path, or deep: 1 in 3.5.EffectScope; effects created in setup are collected and stopped on unmount. effectScope() gives you the same lifecycle outside components, which is how libraries and shared composables avoid leaks. getCurrentScope/onScopeDispose are the component-agnostic versions of onUnmounted.triggerRef, customRef, toRef, toValue. triggerRef forces a shallow ref's dependents to run after an in-place mutation; customRef builds a ref with custom tracking (debounced refs); toRef(obj, 'key') links a ref to a property; toValue() normalises a value, ref or getter, which is how composables accept MaybeRefOrGetter.The six ways to lose reactivity, side by side:
export function useReactivityTraps() {
const state = reactive({ count: 0, user: { name: 'Ada' }, list: [1, 2, 3] })
// 1. Destructuring a reactive object copies plain values.
const { count } = state // count is a number, frozen at 0
const countRef = toRef(state, 'count') // ✓ stays linked
// 2. Spreading does the same.
const copy = { ...state } // plain object, no tracking
const linked = toRefs(state) // ✓ an object of refs
// 3. Replacing a reactive object breaks every existing reference to it.
// state = reactive({}) is a type error; `let` would compile and silently orphan the template.
const holder = ref({ count: 0 }) // ✓ a ref can be replaced: holder.value = { count: 1 }
// 4. A shallow ref does not notice nested mutation.
const rows = shallowRef([{ id: 1 }])
rows.value.push({ id: 2 }) // nothing updates
rows.value = [...rows.value, { id: 3 }] // ✓ replace, or triggerRef(rows) after mutating
// 5. Mutating the raw object bypasses the Proxy.
const raw = toRaw(state)
raw.count++ // no effect runs; only do this deliberately for performance
// 6. Reading in the wrong place: an effect only tracks what it read during its last run.
watchEffect(() => {
if (state.count > 5) console.log(state.user.name) // user.name is tracked only once count > 5
})
return { state, countRef, copy, linked, holder, rows }
}
watch versus watchEffect, and the cleanup that stops a race:
export function useSearch(query: Ref<string>) {
const results = ref<string[]>([])
watch(query, async (q, _old, onCleanup) => {
const controller = new AbortController()
onCleanup(() => controller.abort()) // runs before the next call and on unmount
if (!q) {
results.value = []
return
}
results.value = await $fetch<string[]>('/api/search', { query: { q }, signal: controller.signal })
}, { immediate: true })
// A derived value is a computed, never a watcher writing into a ref.
const count = computed(() => results.value.length)
return { results, count }
}
A debounced ref with customRef, the canonical example of building your own tracking:
export function debouncedRef<T>(initial: T, delay = 250) {
let timeout: ReturnType<typeof setTimeout> | undefined
return customRef<T>((track, trigger) => {
let value = initial
return {
get() {
track()
return value
},
set(next) {
clearTimeout(timeout)
timeout = setTimeout(() => {
value = next
trigger()
}, delay)
},
}
})
}
watch(items, () => { total.value = sum(items.value) }) works until someone reads total before the watcher has flushed, or adds a second source and forgets to list it. Derived state is a computed; watchers are for side effects (fetching, logging, syncing to storage). Interviewers use this exact smell in "review this code" questions.
watch(store, cb, { deep: true }) traverses the whole object on every change to compare it. On a 10,000-row table that is a visible stall. Watch the specific property, use shallowRef and replace, or deep: 1 (3.5) when one level is enough.
The watch options list (deep accepting a number, once, flush, onTrack/onTrigger) and the 3.5 additions pause()/resume()/stop() on the returned handle. Re-check the current API page before quoting option names.
watch that computes a total with a computed and confirm the template updates identically. Then add console.count to both and compare how often they run while typing into an input.debouncedRef and bind it to a search input; confirm that the network call fires once per pause, then add onCleanup cancellation and check the Network panel for cancelled requests."ref is my default: it holds any value, can be replaced, and the .value makes the reactive boundary visible. reactive for a grouped object I mutate in place and never reassign, knowing that destructuring it loses tracking. shallowRef for large data, third-party instances or anything I replace wholesale rather than mutate, because deep tracking of ten thousand rows costs real time. computed for anything derived, because it is lazy and cached and it keeps derived state out of watchers. Watchers are for side effects only: fetching, syncing to storage, logging."
"watch has an explicit source and a callback that gets the old and new values; it is lazy by default and only tracks the source. watchEffect runs immediately and tracks whatever it reads, so it is a 'keep this in sync' tool rather than a 'react to this change' tool. Both take onCleanup, both default to flushing before render, and both are stopped with the component's effect scope. I reach for watch when the trigger matters and watchEffect when the dependencies are the point; and if I catch myself writing a derived value in either, that is a computed."
Nothing yet. Notes filed here show up on this page and the section overview.
Vue 3 in depth
How senior Vue questions are asked, the mental model of the reactivity system and the component model, and how the pages in this section fit together.
Component design
Props, emits and defineModel, slots and their types, attribute fallthrough, provide/inject, defineExpose and generic components, designed so that a team cannot misuse them.