| |

Vue.js 24 🟢 Deep Watchers and Immediate Watcher Execution Options

A watcher reacts to a change. The default watcher reacts to the reference change: the ref’s value is replaced, or the reactive property is assigned. A nested mutation inside a reactive object does not change the reference, and the default watcher does not see it. The deep: true option makes the watcher traverse the object and track the nested properties, so a mutation inside the object triggers the callback. The immediate: true option makes the watcher run on the creation, with the initial value. Both options change the timing and the scope of the watcher, and both have the performance and the correctness implications that make them worth understanding.

Key point: A deep watcher traverses the watched value on every change and tracks every nested property. It is the tool for the reactive objects whose nested state matters, and it is the performance cost for the large objects. An immediate watcher runs the callback on the creation, with the initial value as the first argument and undefined as the old value. The two options combine: { deep: true, immediate: true } watches the nested state and runs the callback on the creation with the initial state.


Why the deep and the immediate options exist

The reference-change problem. A watcher on a ref reacts when the ref.value is replaced. A watcher on a reactive object reacts when a top-level property is assigned. The nested mutation—state.user.name = 'Bob'—does not change the state.user reference, and the default watcher does not run. The deep: true option is the fix: the watcher traverses the object and tracks the nested properties.

The performance problem. The deep traversal has the cost. On a large object, the traversal runs on every change, and the cost is proportional to the object’s size. The deep watch should be used with the awareness of the cost, and the shallow watch should be the default.

The initial-state problem. The watcher is lazy: the callback does not run on the initial value. The side effect that should run on the creation—the data fetch on the mount, the localStorage read, the initial validation—needs the immediate: true option. The callback runs immediately with the initial value.

The old-value problem. The immediate run has the oldValue of undefined, because there is no previous value. The callback should handle the undefined when it uses the old value. The pattern is the guard: if (oldValue !== undefined).

The source problem. The deep watch applies to the reactive objects, the refs that hold objects, and the getters that return objects. The source determines the traversal. A deep watch on a ref that holds a primitive is a no-op because the primitive has no nested properties.

The deep and the getter problem. A deep watch on a getter that returns a computed value does not work the way it seems. The getter returns a new object on every evaluation, and the deep watch traverses the new object. The watcher runs on every dependency change, and the traversal is on the new value. The computed is the better tool for the derived value, and the deep watch is for the reactive source.


a. The deep watcher

The deep: true option watches the nested properties of a reactive object or a ref that holds an object.

<script setup>
import { reactive, watch } from 'vue'

const state = reactive({
  user: {
    name: 'Alice',
    address: {
      city: 'Springfield'
    }
  }
})

watch(
  () => state.user,
  (newValue, oldValue) => {
    console.log('user changed')
  },
  { deep: true }
)

state.user.name = 'Bob'  // triggers the watcher
state.user.address.city = 'Shelbyville'  // triggers the watcher

The watcher runs when the name or the city changes, even though the state.user reference is the same. The deep watch traverses the state.user object and tracks the nested properties.

The deep watch on a reactive object watches the whole object.

<script setup>
import { reactive, watch } from 'vue'

const state = reactive({ count: 0, name: 'Alice' })

watch(state, (newValue, oldValue) => {
  console.log('state changed')
}, { deep: true })

state.count = 1  // triggers
state.name = 'Bob'  // triggers

The watcher runs when the count or the name changes. The reactive object is watched deeply.

The deep watch on a ref that holds an object.

<script setup>
import { ref, watch } from 'vue'

const user = ref({ name: 'Alice', address: { city: 'Springfield' } })

watch(user, (newValue) => {
  console.log('user changed')
}, { deep: true })

user.value.name = 'Bob'  // triggers
user.value = { name: 'Carol' }  // triggers

The watcher runs on the nested mutation and on the replacement. The ref’s .value is the object, and the deep watch tracks the object’s properties.

The deep watch does not work on a ref that holds a primitive.

<script setup>
import { ref, watch } from 'vue'

const count = ref(0)

watch(count, (value) => {
  console.log(value)
}, { deep: true })

count.value = 1  // triggers (the ref's value changed)

The deep: true is a no-op for the primitive. The ref’s value change is the trigger, and the deep traversal has nothing to traverse.

The deep watch on the getter that returns the object.

<script setup>
watch(
  () => state.user,
  (newValue) => {
    console.log('user changed')
  },
  { deep: true }
)

The getter returns the state.user object. The deep watch traverses the returned object. The watcher runs on the nested changes of the state.user.

The performance cost of the deep watch.

<script setup>
const largeState = reactive({
  items: Array.from({ length: 10000 }, (_, i) => ({ id: i, value: i }))
})

watch(largeState, () => {
  // Traverses 10,000 items on every change
}, { deep: true })

The deep watch traverses the 10,000 items on every change. The cost is the traversal, and the large objects should use the shallow watch or the specific getter.

The deep watch on the specific nested property is the alternative.

<script setup>
watch(
  () => state.user.name,
  (newName) => {
    console.log(`name: ${newName}`)
  }
)

The getter returns the specific property. The watcher runs when the name changes, and the traversal is the single property. The specific getter is the performance-friendly alternative to the deep watch.


b. The immediate watcher

The immediate: true option runs the callback on the creation.

<script setup>
import { ref, watch } from 'vue'

const count = ref(0)

watch(count, (newValue, oldValue) => {
  console.log(`count: ${newValue}`)
}, { immediate: true })

// Logs: 'count: 0' immediately

The callback runs with the initial value. The oldValue is undefined because there is no previous value.

The immediate run and the old value.

<script setup>
watch(count, (newValue, oldValue) => {
  if (oldValue === undefined) {
    console.log('initial run')
  } else {
    console.log(`changed from ${oldValue} to ${newValue}`)
  }
}, { immediate: true })

The guard distinguishes the initial run from the subsequent runs. The oldValue === undefined is the signal.

The immediate run is the pattern for the data fetch on the mount.

<script setup>
import { ref, watch } from 'vue'

const userId = ref(1)
const user = ref(null)

watch(userId, async (id) => {
  user.value = await fetchUser(id)
}, { immediate: true })

The watcher runs on the creation, and the user is fetched for the initial userId. The subsequent userId changes trigger the fetch again.

The immediate run is the pattern for the localStorage read.

<script setup>
import { ref, watch } from 'vue'

const theme = ref('light')

watch(theme, (value) => {
  localStorage.setItem('theme', value)
}, { immediate: true })

The watcher writes the theme to the localStorage on the creation and on every change. The initial write is the immediate run.

The immediate run is the pattern for the validation.

<script setup>
import { ref, watch } from 'vue'

const email = ref('')
const isValid = ref(false)

watch(email, (value) => {
  isValid.value = value.includes('@') && value.includes('.')
}, { immediate: true })

The watcher validates the email on the creation and on every change. The isValid is the initial value and the subsequent value.

The immediate run with the computed.

<script setup>
import { ref, computed, watch } from 'vue'

const items = ref([])
const total = computed(() => items.value.reduce((s, i) => s + i.price, 0))

watch(total, (value) => {
  console.log(`total: ${value}`)
}, { immediate: true })

The watcher runs on the creation with the initial total (0) and on every change. The computed is the source, and the immediate run is the initial log.


c. The combination and the comparison

The deep: true and the immediate: true combine.

<script setup>
import { reactive, watch } from 'vue'

const form = reactive({
  name: '',
  email: '',
  address: {
    city: '',
    zip: ''
  }
})

watch(form, (value) => {
  saveDraft(value)
}, { deep: true, immediate: true })

The watcher runs on the creation with the initial form and on every nested change. The draft is saved on the mount and on every edit.

The combination is the pattern for the autosave, the draft persistence, and the real-time validation of the complex forms.

The deep watch and the immediate watch have the distinct purposes.

OptionPurposeCost
deep: trueWatch the nested changesTraversal per change
immediate: trueRun on the creationNone

The deep watch is the scope, and the immediate watch is the timing. The two are independent, and they combine.

The watch and the watchEffect with the deep and the immediate.

FeaturewatchwatchEffect
Deepdeep: true optionAutomatic
Immediateimmediate: true optionAlways immediate
SourceExplicitAutomatic
Old valueYesNo

The watchEffect is always immediate, so the immediate option does not exist. The watchEffect is always deep, so the deep option does not exist. The watch has the options because the source is explicit and the timing is lazy.

The deep watch on the large object is the performance concern. The alternatives:

  1. The specific getter: watch(() => state.user.name, callback).
  2. The shallowRef for the large data.
  3. The shallowReactive for the top-level-only reactivity.
  4. The watchEffect if the automatic tracking is the fit.

The shallowRef and the shallowReactive.

<script setup>
import { shallowReactive, watch } from 'vue'

const state = shallowReactive({
  user: { name: 'Alice', address: { city: 'Springfield' } }
})

watch(state, () => {
  console.log('top-level change')
})

state.user = { name: 'Bob' }  // triggers
state.user.name = 'Carol'  // does NOT trigger

The shallowReactive only tracks the top-level properties. The nested mutation does not trigger the watcher. The shallow reactivity is the performance optimization for the large objects where only the top-level changes matter.

The deep: false is the default.

<script setup>
watch(state, callback)  // shallow by default

The shallow watch runs when the top-level property is assigned. The nested mutation does not trigger it. The deep option is the explicit opt-in.


Complete Example Session

<!-- ============================================ -->
<!-- PART 1: DEEP WATCH ON REACTIVE -->
<!-- ============================================ -->
<script setup>
import { reactive, watch } from 'vue'
const state = reactive({ user: { name: 'Alice', address: { city: 'Springfield' } } })

watch(() => state.user, (value) => {
  console.log('user changed')
}, { deep: true })

state.user.name = 'Bob'  // triggers
</script>
<!-- ============================================ -->
<!-- PART 2: DEEP WATCH ON REF -->
<!-- ============================================ -->
<script setup>
import { ref, watch } from 'vue'
const user = ref({ name: 'Alice', address: { city: 'Springfield' } })

watch(user, (value) => {
  console.log('user changed')
}, { deep: true })

user.value.name = 'Bob'  // triggers
</script>
<!-- ============================================ -->
<!-- PART 3: DEEP WATCH WHOLE REACTIVE -->
<!-- ============================================ -->
<script setup>
const state = reactive({ count: 0, name: 'Alice' })

watch(state, () => {
  console.log('state changed')
}, { deep: true })

state.count = 1  // triggers
state.name = 'Bob'  // triggers
</script>
<!-- ============================================ -->
<!-- PART 4: SPECIFIC GETTER INSTEAD -->
<!-- ============================================ -->
<script setup>
watch(() => state.user.name, (newName) => {
  console.log(`name: ${newName}`)
})
</script>
<!-- ============================================ -->
<!-- PART 5: IMMEDIATE -->
<!-- ============================================ -->
<script setup>
const count = ref(0)

watch(count, (newValue, oldValue) => {
  console.log(`count: ${newValue}`)
}, { immediate: true })
// Logs immediately
</script>
<!-- ============================================ -->
<!-- PART 6: IMMEDIATE WITH OLD VALUE GUARD -->
<!-- ============================================ -->
<script setup>
watch(count, (newValue, oldValue) => {
  if (oldValue === undefined) {
    console.log('initial run')
  } else {
    console.log(`changed from ${oldValue} to ${newValue}`)
  }
}, { immediate: true })
</script>
<!-- ============================================ -->
<!-- PART 7: IMMEDIATE FETCH -->
<!-- ============================================ -->
<script setup>
const userId = ref(1)
const user = ref(null)

watch(userId, async (id) => {
  user.value = await fetchUser(id)
}, { immediate: true })
</script>
<!-- ============================================ -->
<!-- PART 8: COMBINED -->
<!-- ============================================ -->
<script setup>
const form = reactive({ name: '', email: '', address: { city: '', zip: '' } })

watch(form, (value) => {
  saveDraft(value)
}, { deep: true, immediate: true })
</script>
<!-- ============================================ -->
<!-- PART 9: SHALLOW REACTIVE -->
<!-- ============================================ -->
<script setup>
import { shallowReactive, watch } from 'vue'
const state = shallowReactive({ user: { name: 'Alice' } })

watch(state, () => console.log('top-level'))

state.user = { name: 'Bob' }  // triggers
state.user.name = 'Carol'  // does NOT trigger
</script>
<!-- ============================================ -->
<!-- PART 10: DEEP ON LARGE OBJECT ALTERNATIVE -->
<!-- ============================================ -->
<script setup>
// BAD: deep watch on 10,000 items
// watch(largeState, callback, { deep: true })

// GOOD: specific getter
watch(() => largeState.items.length, (length) => {
  console.log(`length: ${length}`)
})
</script>

The ten parts covered the deep watch on the reactive, the deep watch on the ref, the deep watch on the whole reactive, the specific getter instead, the immediate, the immediate with the old value guard, the immediate fetch, the combined, the shallowReactive, and the deep watch on the large object alternative.


Quick Reference

Deep Watch

SourceDeep Works
reactive objectYes
ref holding objectYes
ref holding primitiveNo-op
Getter returning objectYes
Getter returning primitiveNo-op

Immediate Watch

AspectBehavior
Runs on creationYes
newValueInitial value
oldValueundefined
UseFetch, validation, init

Combined

OptionsBehavior
{ deep: true }Watch nested
{ immediate: true }Run on creation
{ deep: true, immediate: true }Both

Performance Alternatives

AlternativeUse
Specific getterOnly one property matters
shallowRefLarge data, replace-only
shallowReactiveTop-level changes only
watchEffectAutomatic tracking

watch vs watchEffect

FeaturewatchwatchEffect
DeepOptionAutomatic
ImmediateOptionAlways
Old valueYesNo
SourceExplicitAutomatic

Best Practices

✅ Do This:

<!-- Use deep: true for the nested state -->
watch(() => state.user, callback, { deep: true })               // ✅

<!-- Use immediate: true for the initial run -->
watch(count, callback, { immediate: true })                     // ✅

<!-- Combine for the autosave -->
watch(form, saveDraft, { deep: true, immediate: true })         // ✅

<!-- Use the specific getter for the single property -->
watch(() => state.user.name, callback)                          // ✅

<!-- Use the guard for the immediate old value -->
watch(count, (newVal, oldVal) => {
  if (oldVal === undefined) return init()
  // ...
}, { immediate: true })                                        // ✅

<!-- Use shallowReactive for the large data -->
const state = shallowReactive({ /* ... */ })                    // ✅

❌ Don’t Do This:

<!-- Don't deep watch the large objects -->
watch(largeState, callback, { deep: true })                     // ⚠️

<!-- Don't forget the old value is undefined on the immediate run -->
watch(count, (newVal, oldVal) => {
  console.log(oldVal.length)  // error on immediate                // ❌
}, { immediate: true })

<!-- Don't use deep when the specific getter works -->
watch(() => state.user, callback, { deep: true })               // ⚠️
// prefer: watch(() => state.user.name, callback)

<!-- Don't expect deep to work on a primitive ref -->
watch(count, callback, { deep: true })  // no nested              // ⚠️

<!-- Don't use the immediate option for the watchEffect -->
watchEffect(callback, { immediate: true })  // always immediate    // ❌

<!-- Don't forget the deep watch is expensive -->
watch(10000Items, callback, { deep: true })                     // ❌

Common Pitfalls

PitfallWhy It HappensFix
Nested change not caughtNo deep optionAdd deep: true
Old value is undefinedImmediate runGuard the old value
Deep watch slowLarge objectUse the specific getter
Deep on primitiveNo nested propertiesNo-op, use the ref
Watch not runningLazy by defaultUse immediate: true
Deep on the getterReturns the new objectUse the computed

Real-World Examples

1. Deep Form Watch

watch(form, (value) => {
  saveDraft(value)
}, { deep: true })

2. Immediate Fetch

watch(userId, async (id) => {
  user.value = await fetchUser(id)
}, { immediate: true })

3. Combined Autosave

watch(form, saveDraft, { deep: true, immediate: true })

4. Specific Property

watch(() => state.user.name, (name) => {
  console.log(name)
})

5. Immediate Validation

watch(email, (value) => {
  isValid.value = value.includes('@')
}, { immediate: true })

6. Deep Array Watch

watch(items, (value) => {
  console.log(`items: ${value.length}`)
}, { deep: true })

7. Immediate localStorage Write

watch(theme, (value) => {
  localStorage.setItem('theme', value)
}, { immediate: true })

8. Deep with Old Value

watch(form, (newVal, oldVal) => {
  console.log('changed', newVal, oldVal)
}, { deep: true, immediate: true })

9. Shallow Reactive

const state = shallowReactive({ user: { name: 'Alice' } })
watch(state, () => console.log('top-level'))

10. Getter Instead of Deep

watch(() => state.user.name, (name) => {
  console.log(name)
})

Visual

Deep Watch

┌─────────────────────────────────────────────────────────────┐
│  const state = reactive({                                   │
│    user: { name: 'Alice', address: { city: 'Springfield' } }│
│  })                                                         │
│                                                             │
│  watch(() => state.user, callback, { deep: true })          │
│                                                             │
│  state.user.name = 'Bob'                                    │
│    │                                                        │
│    ▼                                                        │
│  Deep watch traverses state.user                            │
│    │                                                        │
│    ▼                                                        │
│  name changed ──▶ callback runs                             │
│                                                             │
│  state.user.address.city = 'Shelbyville'                    │
│    │                                                        │
│    ▼                                                        │
│  Deep watch traverses state.user                            │
│    │                                                        │
│    ▼                                                        │
│  city changed ──▶ callback runs                             │
│                                                             │
└─────────────────────────────────────────────────────────────┘

Immediate Watch

┌─────────────────────────────────────────────────────────────┐
│  const count = ref(0)                                       │
│  watch(count, callback, { immediate: true })                │
│    │                                                        │
│    ▼                                                        │
│  Callback runs immediately                                  │
│    │                                                        │
│    ├── newValue = 0                                         │
│    │                                                        │
│    └── oldValue = undefined                                 │
│                                                             │
│  count.value = 1                                            │
│    │                                                        │
│    ▼                                                        │
│  Callback runs                                              │
│    │                                                        │
│    ├── newValue = 1                                         │
│    │                                                        │
│    └── oldValue = 0                                         │
│                                                             │
└─────────────────────────────────────────────────────────────┘

Shallow vs Deep

┌─────────────────────────────────────────────────────────────┐
│  SHALLOW WATCH (default)                                    │
│                                                             │
│  watch(state, callback)                                     │
│                                                             │
│  state.user = { name: 'Bob' }  → triggers                   │
│  state.user.name = 'Carol'     → does NOT trigger           │
│                                                             │
│  Tracks the top-level reference only.                       │
│                                                             │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  DEEP WATCH                                                 │
│                                                             │
│  watch(state, callback, { deep: true })                     │
│                                                             │
│  state.user = { name: 'Bob' }  → triggers                   │
│  state.user.name = 'Carol'     → triggers                   │
│                                                             │
│  Tracks all the nested properties.                          │
│                                                             │
└─────────────────────────────────────────────────────────────┘

Performance Alternatives

┌─────────────────────────────────────────────────────────────┐
│  PROBLEM: deep watch on the 10,000 items                    │
│                                                             │
│  Traverses all the items on every change.                   │
│                                                             │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  ALTERNATIVES                                               │
│                                                             │
│  1. Specific getter                                         │
│     watch(() => items.length, callback)                     │
│                                                             │
│  2. shallowRef                                              │
│     const items = shallowRef([...])                         │
│     // only the .value replacement triggers                 │
│                                                             │
│  3. shallowReactive                                         │
│     const state = shallowReactive({ items: [...] })         │
│     // only the top-level triggers                          │
│                                                             │
│  4. watchEffect                                             │
│     watchEffect(() => console.log(items.value.length))      │
│                                                             │
└─────────────────────────────────────────────────────────────┘

Summary

ItemValue
Deep watch{ deep: true }
WatchesNested properties
CostTraversal per change
Immediate watch{ immediate: true }
RunsOn creation
oldValueundefined
Combined{ deep: true, immediate: true }
Specific getterPerformance alternative
shallowRefReplace-only
shallowReactiveTop-level only
watchEffectAutomatic deep + immediate

Key takeaways:

  • The deep watch tracks the nested properties. The default watch reacts to the reference change. The deep: true option traverses the object and tracks the nested properties, so the nested mutation triggers the callback.
  • The deep watch has the performance cost. The traversal runs on every change, and the cost is proportional to the object’s size. The specific getter is the performance-friendly alternative when only one property matters.
  • The immediate watch runs on the creation. The callback runs with the initial value, and the oldValue is undefined. The guard distinguishes the initial run from the subsequent runs.
  • The immediate watch is for the initial side effects. The data fetch on the mount, the localStorage read, the initial validation. The lazy default is the opposite: the side effect that should not run on the creation.
  • The two options combine. The { deep: true, immediate: true } watches the nested state and runs the callback on the creation. The pattern is the autosave, the draft persistence, and the real-time validation.
  • The shallowRef and the shallowReactive are the performance optimizations. The shallowRef only tracks the .value replacement. The shallowReactive only tracks the top-level properties. The nested mutations do not trigger the watchers.
  • The watchEffect is always immediate and always deep. The options do not exist because the behavior is the default. The watch has the options because the source is explicit and the timing is lazy.

Remember: The deep watch is the scope and the immediate watch is the timing. The deep watch is for the nested state, and the immediate watch is for the initial run. The two combine for the autosave and the draft persistence. But the deep watch has the cost, and the specific getter is the alternative when only one property matters. The shallowRef and the shallowReactive are the optimizations for the large data. And remember the oldValue is undefined on the immediate run; guard it when the callback uses the old value.



Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!