skip to content

In Vue 3, how do watch()'s deep, immediate and once options change when and how often the callback runs?

level: middleimportance: should knowfreq 60%

answer

  1. lazy and shallow by default
  2. traversal, capped by a number in 3.5
  3. eager first call, undefined old value
  4. once stops after one callback (3.4)

basics

~20 s

deep makes a ref or getter source fire on nested mutations, and since 3.5 a number caps traversal depth; immediate runs the callback at creation with an undefined old value; once (3.4+) stops the watcher after its first callback.

solid answer

~40 s

`watch()` is lazy, and shallow for ref and getter sources. `deep: true` makes Vue traverse the source's nested properties so a mutation anywhere fires the callback; a reactive-object source is already deep, and `deep: false` on it limits tracking to root-level properties. Since Vue 3.5, `deep` also accepts a number, the maximum traversal depth, which bounds the cost on large structures. `immediate: true` runs the callback once at creation with `undefined` as the old value, which suits load-then-reload logic. `once: true` (3.4+) stops the watcher after its first callback; combined with `immediate`, that first callback is the eager one, so it never reacts to a change. `flush` is a separate timing option.

code

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

const id = ref(1)
const state = reactive({ filters: { category: 'all', range: { min: 0, max: 100 } } })

// immediate: load now, reload on every id change
watch(id, (next, prev) => console.log('load', next, 'was', prev), { immediate: true })
// logs: load 1 was undefined

// numeric deep (3.5+): root-level fields of filters only
watch(() => state.filters, () => console.log('filters changed'), { deep: 1 })
state.filters.category = 'books' // fires
state.filters.range.min = 5 // does not fire

// once (3.4+): react to the first change, then stop
watch(id, (next) => console.log('first change to', next), { once: true })

go deeper

for a junior

Know the three options: deep for nested changes, immediate to run at creation, once to run a single time. watch() is lazy without immediate.

for a middle

Explain deep as traversal that tracks nested reads, the numeric depth cap in 3.5, the undefined first old value, and how once interacts with immediate.

for a senior

Judge cost: deep traversal on big structures runs every trigger; prefer narrow getters or numeric depth, and use immediate instead of duplicated setup calls.

for a principal

Set conventions for deep watching in shared code: when a deep watcher needs a written justification, and when data shape should change instead.

## The defaults Vue 3's `watch(source, callback, options)` is **lazy** and, for ref and getter sources, **shallow** by default: - lazy: the callback does not run when the watcher is created, only after the source changes; - shallow: a ref or getter source fires when its value is replaced or its returned value changes, not when something nested inside that value is mutated; - the exception is a **reactive object** passed directly, which is watched deeply without any option. The API reference types list `immediate?: boolean` (default `false`), `deep?: boolean | number` (default `false`) and `once?: boolean` (default `false`, 3.4+). `flush` is a fourth option that controls *when* in the update cycle the callback runs; it is a separate timing topic. ## deep `deep: true` makes Vue **traverse** the source's value on each run, reading every nested property so that each one becomes a tracked dependency. Two effects follow: 1. any nested mutation re-runs the watcher; 2. in deep mode the callback fires on every such trigger without comparing old and new values, which is also why the two are often the same object. | Source | no `deep` | `deep: true` | `deep: false` | `deep: N` (3.5+) | |---|---|---|---|---| | ref | `.value` replaced | any nested change | as default | nested up to N levels | | getter | return value changes | any nested change | as default | nested up to N levels | | reactive object | any nested change (implicit) | any nested change | root-level properties only | nested up to N levels | Traversal walks plain objects, arrays, `Map`s and `Set`s, and skips values marked with `markRaw()`. It runs on every trigger, so on a large structure the tracking itself becomes the cost. ## Numeric depth (Vue 3.5+) Since 3.5, `deep` also accepts a number: the **maximum traversal depth**. `deep: 1` tracks the root-level properties of the value the source returns; `deep: 2` also tracks the properties of objects one level down, and so on. For `watch(() => state.filters, cb, { deep: 1 })`, assigning `state.filters.category` fires the callback, but `state.filters.range.min = 5` does not, because `range` is read as a value and not entered. Numeric depth is the tool for a big nested structure where only the top layers matter. ## immediate `immediate: true` runs the callback **once at creation**, during the `watch()` call itself, and then continues lazily. On that first call the old value is `undefined` for a single source; for an array source the old-values argument is an empty array. The canonical use is load-then-reload: fetch data for the current id at setup and again whenever it changes, with one callback instead of a duplicated initial call. ## once (Vue 3.4+) `once: true` wraps the callback so that the watcher **stops itself after the callback's first run**. It suits one-shot reactions such as 'when the first result arrives, focus the list'. The combination with `immediate` is a known edge: | Options | Callback runs | |---|---| | none | on every change | | `immediate` | at creation, then on every change | | `once` | on the first change only | | `immediate` + `once` | at creation only; never for a later change | ## Where the options do nothing `deep`, `immediate` and `once` belong to the `watch(source, callback, options)` signature. Passing them to `watchEffect()` has no effect, and in development Vue warns that the option is only respected when using that signature. ## Picking options in practice - Reach for a narrower getter before reaching for `deep: true`. - Use a number for `deep` when the structure is large and the relevant changes are shallow. - Use `immediate` instead of duplicating the callback body at the top of `setup`. - Use `once` for genuinely one-time reactions, and remember what `immediate` does to it.

  • What does `watch(src, cb, { immediate: true, once: true })` do?
    It runs the callback once at creation and then stops the watcher, so it never reacts to a later change. `once` counts the eager call as the first callback. If you want 'run now and on the first change', drop `once` and stop the watcher yourself after the second call, or use two watchers.
  • What does `deep: false` do on a reactive object source?
    It limits tracking to the object's root-level properties. Assigning `state.category` still fires the callback, but mutating `state.range.min` does not, because traversal does not enter nested objects. Without the option, a reactive object source is traversed fully.

saying these in an interview costs you the question

  • immediate: true passes the current value as oldValue on the first call
  • deep: true is needed to see nested changes on a reactive object source
  • once: true means the callback runs once per component render
  • A numeric deep value is a debounce delay in milliseconds
  • deep and immediate work the same way when passed to watchEffect()