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.
| Option | Purpose | Cost |
|---|---|---|
deep: true | Watch the nested changes | Traversal per change |
immediate: true | Run on the creation | None |
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.
| Feature | watch | watchEffect |
|---|---|---|
| Deep | deep: true option | Automatic |
| Immediate | immediate: true option | Always immediate |
| Source | Explicit | Automatic |
| Old value | Yes | No |
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:
- The specific getter:
watch(() => state.user.name, callback). - The
shallowReffor the large data. - The
shallowReactivefor the top-level-only reactivity. - The
watchEffectif 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
| Source | Deep Works |
|---|---|
reactive object | Yes |
ref holding object | Yes |
ref holding primitive | No-op |
| Getter returning object | Yes |
| Getter returning primitive | No-op |
Immediate Watch
| Aspect | Behavior |
|---|---|
| Runs on creation | Yes |
newValue | Initial value |
oldValue | undefined |
| Use | Fetch, validation, init |
Combined
| Options | Behavior |
|---|---|
{ deep: true } | Watch nested |
{ immediate: true } | Run on creation |
{ deep: true, immediate: true } | Both |
Performance Alternatives
| Alternative | Use |
|---|---|
| Specific getter | Only one property matters |
shallowRef | Large data, replace-only |
shallowReactive | Top-level changes only |
watchEffect | Automatic tracking |
watch vs watchEffect
| Feature | watch | watchEffect |
|---|---|---|
| Deep | Option | Automatic |
| Immediate | Option | Always |
| Old value | Yes | No |
| Source | Explicit | Automatic |
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
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Nested change not caught | No deep option | Add deep: true |
Old value is undefined | Immediate run | Guard the old value |
| Deep watch slow | Large object | Use the specific getter |
| Deep on primitive | No nested properties | No-op, use the ref |
| Watch not running | Lazy by default | Use immediate: true |
| Deep on the getter | Returns the new object | Use 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
| Item | Value |
|---|---|
| Deep watch | { deep: true } |
| Watches | Nested properties |
| Cost | Traversal per change |
| Immediate watch | { immediate: true } |
| Runs | On creation |
oldValue | undefined |
| Combined | { deep: true, immediate: true } |
| Specific getter | Performance alternative |
shallowRef | Replace-only |
shallowReactive | Top-level only |
watchEffect | Automatic deep + immediate |
Key takeaways:
- The deep watch tracks the nested properties. The default watch reacts to the reference change. The
deep: trueoption 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
oldValueisundefined. 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
shallowRefand theshallowReactiveare the performance optimizations. TheshallowRefonly tracks the.valuereplacement. TheshallowReactiveonly tracks the top-level properties. The nested mutations do not trigger the watchers. - The
watchEffectis always immediate and always deep. The options do not exist because the behavior is the default. Thewatchhas 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!