skip to content

In Vue 3, why is calling watch() directly on a large reactive dataset expensive, and what would you watch instead?

level: middleimportance: should knowfreq 40%

answer

  1. implicitly deep
  2. walks the whole tree each run
  3. old and new are one object
  4. narrow source or version counter

basics

~20 s

watch() on a reactive object is implicitly deep: each run traverses and tracks every nested property, and any nested change re-runs it. Watch a narrow getter, a replaced shallowRef, a version counter, or limit depth with a numeric deep in 3.5.

solid answer

~40 s

When `watch()` receives a reactive object, Vue makes it a deep watcher: its getter traverses every nested property through the proxies, which proxies every nested object and records a dependency for each property. Any mutation anywhere then re-runs the getter, and the whole traversal happens again before the callback fires, so one checkbox in a 20,000-row dataset costs a full walk. The callback also gets the same object as `newValue` and `oldValue`, so it cannot even tell what changed. Cheaper sources: a getter returning the one value you care about; a `shallowRef` that you replace, which is a single `.value` read; a version counter your mutation functions bump; or, in Vue 3.5+, `deep` as a number to cap traversal depth.

code

ts · 28 lines
ts
import { reactive, ref, shallowRef, watch } from 'vue'

interface Row { id: number; done: boolean; payload: Record<string, unknown> }

declare function save(rows: Row[]): void
declare function loadDetails(id: number | null): void

// Costly: an implicit deep watch walks every row and payload on each run.
const state = reactive({ rows: [] as Row[], selectedId: null as number | null })
watch(state, () => save(state.rows))

// Cheaper: watch the one field you need.
watch(() => state.selectedId, id => loadDetails(id))

// Cheaper: a version counter bumped by the code that mutates rows.
const revision = ref(0)
function toggle(row: Row) {
  row.done = !row.done
  revision.value++
}
watch(revision, () => save(state.rows))

// Cheaper: data held in a shallowRef and replaced on change.
const snapshot = shallowRef<Row[]>([])
watch(snapshot, next => save(next))

// 3.5+: notice added or removed rows without descending into each row.
watch(() => state.rows, next => save(next), { deep: 1 })

go deeper

for a junior

Remember that watch() on a reactive object is deep by default and that a getter can watch just one field.

for a middle

Explain the traversal: each run walks and tracks every nested property, re-runs on any change, and passes the same object as new and old value.

for a senior

Find implicit deep watchers behind input lag on big datasets and replace them with narrow getters, shallow sources or a revision counter.

for a principal

Set a team convention: no deep watchers over collections without a review note, and mutations routed through composables that can signal change.

## What watching a reactive object does `watch(source, callback)` runs a getter to collect dependencies and calls the callback when those dependencies change. The **kind of source** decides what the getter reads: - a **ref** source reads `.value` (and does not descend into it unless `deep` is set); - a **getter** source reads whatever the function reads; - a **reactive object** source is special: Vue makes the watcher **deep implicitly**, so the getter traverses every nested property. The traversal runs through the proxies. Reading each nested object through its reactive parent creates its proxy, and reading each property records a dependency. ## Why that hurts on a big dataset Take `state = reactive({ rows: [...] })` with 20,000 rows of nested objects, and `watch(state, save)`: 1. **Setup** traverses all 20,000 rows and every nested field: proxies are created and tens or hundreds of thousands of dependencies are recorded. 2. **Any mutation** - one `done` flag on one row - invalidates the watcher. 3. The watcher **re-runs the getter**, which traverses the entire structure again to re-collect dependencies, before the callback fires. 4. The **callback** receives the same object as new and old value, because a nested mutation does not produce a new object. Code that wants the change usually diffs the whole dataset again. The Vue guide carries the warning directly: deep watch traverses all nested properties and can be expensive on large data structures. It is a cost per change, not per page load, so it shows up as input lag while the user edits. ## Cheaper things to watch | Source | What each run reads | Fires when | |---|---|---| | `watch(state, cb)` on a reactive object | every nested property | anything anywhere changes | | `watch(() => state.selectedId, cb)` | one property | that property changes | | `watch(rowsShallowRef, cb)` | `.value` only | `.value` is replaced or `triggerRef()` is called | | `watch(revision, cb)` with a counter ref | one number | your code bumps the counter | | `watch(() => state.rows, cb, { deep: 1 })` (3.5+) | the array's indexes and length | items are added, removed or reassigned | Notes on each: - **Narrow getters** are the default fix. Most deep watchers exist because someone wanted 'tell me when anything changes' and only needed one field. - **A replaced `shallowRef`** makes 'the dataset changed' a single notification. Watching it reads `.value` and nothing else. - **A version counter** keeps the dataset deep for editing convenience, while the functions that mutate it increment a `revision` ref. Autosave watches the counter, not the data. - **Numeric `deep`**, added in Vue 3.5, sets the maximum traversal depth. `{ deep: 1 }` on an array reads each index but does not descend into the row objects, so it notices additions, removals and replacements but not a field edited inside a row. It is still linear in the array length. - **`markRaw()`'d objects** are skipped by the traversal, so a raw payload embedded in reactive state does not add to a deep watcher's cost. ## Why rendering does not pay the same price It helps to compare the deep watcher with the component's own render effect. The render effect tracks **only what the template actually reads**: a virtualized table showing 40 rows tracks those 40 rows' displayed fields, and a change elsewhere in the dataset does not re-render it. A deep watcher has no such selectivity - its whole purpose is to read everything. That is why a page can render a big dataset smoothly and still stutter on each edit: the render is narrow, the watcher is not. ## A diagnosis habit When a page with a large dataset lags on every keystroke, search for `deep: true` and for `watch(` calls whose first argument is a `reactive()` object or a store's whole state. Each one is a candidate for a narrower source. Removing the implicit deep watch is often a bigger win than any rendering optimization, because it runs on every single mutation.

  • What does deep: 1 still cost on an array of 20,000 rows?
    Each run reads every index and the length through the proxy, so it is linear in the row count and creates item proxies as it reads them. What it avoids is descending into each row's fields and tracking them, so edits inside a row no longer trigger it. It is a cheaper deep watch, not a free one.
  • Why is a version counter often better than a deep watcher for autosave?
    The watcher reads one number, so each run is constant-time and it fires exactly when your own mutation functions say something meaningful changed. The dataset can stay deeply reactive for convenient editing. The cost is discipline: every mutation path must bump the counter, so keep mutations inside one composable.

saying these in an interview costs you the question

  • Watching a reactive object only fires when the object is replaced.
  • A deep watcher compares newValue and oldValue to show what changed.
  • Deep watching is cheap because Vue only re-checks the changed property.
  • Any number passed to deep behaves like deep: true.
  • Watching a shallowRef traverses its contents on every change.