Vue 3 in depth

Reactivity system

ref, reactive, shallowRef and computed underneath, watch versus watchEffect and flush timing, effect scopes, and the mistakes that make state stop updating.

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

Know

  • 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.
  • Deep by default. 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 timing. Watcher callbacks default to 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.
  • Effect scopes. Every component has an 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.

How it works

The six ways to lose reactivity, side by side:

app/composables/useReactivityTraps.ts
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:

app/composables/useSearch.ts
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:

app/utils/debounced-ref.ts
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)
      },
    }
  })
}
Gotcha· A watcher that writes derived state

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.

Gotcha· Deep watchers on big data

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.

Verify before the interview:

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.

docs ↗

Exercise

Exercise
  • Reproduce all six traps from the first code block in a playground component and fix each one; keep the file as your reference.
  • Replace a 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.
  • Build 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.

Be able to say

Be able to say· When do you use ref, reactive, shallowRef and computed?

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

Be able to say· What is the difference between watch and watchEffect?

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

Your notes

Open in Notes

Nothing yet. Notes filed here show up on this page and the section overview.