Vue.js 22 🟢 Side Effects Management with watch()
A computed derives a value. A watcher reacts to a change. The distinction matters: the computed is for the derived state, and the watcher is for the side effect that runs when a reactive value changes. The watch() function is the Composition API’s mechanism for the side effects. It takes a source, a callback, and options, and it runs the callback when the source changes. The callback receives the new value, the old value, and a cleanup function. The watcher is the tool for the data fetching, the localStorage sync, the log, and the imperative API call.
Key point: watch() is lazy by default. The callback runs when the source changes, not on the initial value. The immediate: true option runs the callback on the creation as well. The deep: true option watches the nested properties of a reactive object. The callback receives the onCleanup function, which registers the cleanup for the next run. The watcher returns a stop function that cancels the watcher when it is no longer needed.
Why watch exists
The side-effect problem. A computed is for deriving a value. A side effect is not a value: the network request, the localStorage write, the console log, the DOM manipulation. These cannot be computed. They must be triggered by a change, and they must be run once per change. The watch() is the mechanism.
The explicit problem. A computed runs when it is read. The timing is implicit. A watch runs when the source changes. The timing is explicit. The side effect should run when the source changes, not when something reads a value. The watch is the explicit trigger.
The lazy problem. A watch is lazy: the callback does not run on the initial value. The immediate: true option makes it eager. The choice is the design decision: the side effect that should run on the initial value and the side effect that should run only on the change.
The old-value problem. The callback receives the new value and the old value. The comparison is the reason for the old value: the change detection, the diff, the animation. The computed does not have the old value.
The cleanup problem. The side effect may be asynchronous: the fetch that is in flight, the timer that is set, the subscription that is created. When the source changes again before the side effect completes, the old side effect should be cancelled. The callback receives the onCleanup function, and the cleanup runs before the next invocation.
The deep problem. A reactive object’s nested properties change without the object reference changing. The deep: true option makes the watcher detect the nested changes. The deep watch has the performance cost, and the shallow watch is the default.
a. The watch source and the callback
The watch() function takes the source, the callback, and the optional options.
<script setup>
import { ref, watch } from 'vue'
const count = ref(0)
watch(count, (newValue, oldValue) => {
console.log(`count changed from ${oldValue} to ${newValue}`)
})
</script>
The source is the count ref. The callback runs when the count changes. The new value and the old value are the arguments.
The source can be a getter function.
<script setup>
import { ref, watch } from 'vue'
const firstName = ref('Alice')
const lastName = ref('Smith')
watch(
() => `${firstName.value} ${lastName.value}`,
(newValue, oldValue) => {
console.log(`full name: ${newValue}`)
}
)
</script>
The getter returns the computed string. The watcher runs when the string changes, which is when the firstName or the lastName changes. The getter source is the mechanism for the derived value that is not a computed.
The source can be an array of multiple sources.
<script setup>
import { ref, watch } from 'vue'
const firstName = ref('Alice')
const lastName = ref('Smith')
watch(
[firstName, lastName],
([newFirst, newLast], [oldFirst, oldLast]) => {
console.log(`changed from ${oldFirst} ${oldLast} to ${newFirst} ${newLast}`)
}
)
</script>
The array source watches the multiple refs. The callback receives the arrays of the new and the old values. The watcher runs when any of the sources changes.
The source can be a reactive object.
<script setup>
import { reactive, watch } from 'vue'
const state = reactive({ count: 0, name: 'Alice' })
watch(state, (newValue, oldValue) => {
console.log('state changed')
})
</script>
The reactive object is watched deeply by default. The callback runs when any of the nested properties changes. The new value and the old value are the same object, which is why the deep watch uses the getter form when the specific value is needed.
b. The immediate, the deep, and the cleanup
The immediate: true option runs the callback on the creation.
<script setup>
import { ref, watch } from 'vue'
const count = ref(0)
watch(count, (newValue) => {
console.log(`count is ${newValue}`)
}, { immediate: true })
// Logs: 'count is 0' immediately
</script>
The callback runs with the initial value. The oldValue is undefined on the immediate run. The pattern is for the side effect that should run on the initial state: the data fetch on the mount, the localStorage read, the initial log.
The deep: true option watches the nested properties.
<script setup>
import { reactive, watch } from 'vue'
const state = reactive({
user: { name: 'Alice', address: { city: 'Springfield' } }
})
watch(
() => state.user,
(newValue) => {
console.log('user changed')
},
{ deep: true }
)
</script>
The watcher runs when the name or the city changes, even though the state.user reference is the same. The deep watch traverses the object and tracks the nested properties. The performance cost is the traversal of the large object on every change.
The cleanup is registered with the onCleanup function.
<script setup>
import { ref, watch } from 'vue'
const query = ref('')
watch(query, async (newQuery, oldQuery, onCleanup) => {
const controller = new AbortController()
onCleanup(() => controller.abort())
const response = await fetch(`/api/search?q=${newQuery}`, {
signal: controller.signal
})
const results = await response.json()
console.log(results)
})
</script>
The onCleanup registers the function that runs before the next invocation and when the watcher is stopped. The controller.abort() cancels the fetch. The pattern is the race condition prevention: the old request is cancelled when the query changes.
The cleanup can be used for the timer, the subscription, and the event listener.
<script setup>
watch(source, (value, oldValue, onCleanup) => {
const timer = setInterval(() => {
console.log(value)
}, 1000)
onCleanup(() => clearInterval(timer))
})
</script>
The interval is cleared before the next run. The pattern is the same as the useEffect cleanup in React.
The flush option controls the timing. The flush: 'pre' (default) runs the callback before the component updates. The flush: 'post' runs it after the update. The flush: 'sync' runs it synchronously on the change.
<script setup>
watch(source, callback, { flush: 'post' })
</script>
The flush: 'post' is for the side effects that read the updated DOM. The default is pre, which is the timing before the DOM update.
The once: true option runs the callback only once.
<script setup>
watch(source, callback, { once: true })
</script>
The watcher runs on the first change and stops. The pattern is for the one-time side effect.
c. The watchEffect and the stop
The watchEffect() is the alternative to the watch(). It runs the callback immediately and tracks the reactive dependencies automatically.
<script setup>
import { ref, watchEffect } from 'vue'
const count = ref(0)
const doubled = ref(0)
watchEffect(() => {
doubled.value = count.value * 2
})
The callback runs immediately, reads the count, and assigns the doubled. The count is tracked as the dependency. The callback re-runs when the count changes.
The watchEffect and the watch differences:
| Aspect | watch | watchEffect |
|---|---|---|
| Run | Lazy | Immediate |
| Dependencies | Explicit | Automatic |
| Old value | Yes | No |
| Use | Specific source | Multiple dependencies |
The watch is the explicit source and the old value. The watchEffect is the automatic dependency tracking. The watchEffect is for the side effect that reads multiple reactive values and does not need the old value.
The watchEffect returns the stop function.
<script setup>
import { ref, watchEffect } from 'vue'
const count = ref(0)
const stop = watchEffect(() => {
console.log(count.value)
})
stop() // stop the watcher
The stop() cancels the watcher. The pattern is for the watcher that should run only for the certain period.
The watch returns the stop function.
<script setup>
import { ref, watch } from 'vue'
const count = ref(0)
const stop = watch(count, (value) => {
console.log(value)
})
stop() // stop the watcher
The stop function is the same. The watcher is stopped when the component unmounts, and the explicit stop() is for the cases where the watcher should stop before the unmount.
The onWatcherCleanup() is the alternative to the onCleanup argument in the Vue 3.5+.
<script setup>
import { watch, onWatcherCleanup } from 'vue'
watch(count, () => {
const controller = new AbortController()
onWatcherCleanup(() => controller.abort())
})
</script>
The onWatcherCleanup is called inside the watcher callback and registers the cleanup. It is the newer API and the onCleanup argument is the older one.
The watcher is automatically stopped when the component unmounts. The cleanup runs, and the resources are released. The explicit stop() is only for the watchers that should stop earlier.
Complete Example Session
<!-- ============================================ -->
<!-- PART 1: BASIC WATCH -->
<!-- ============================================ -->
<script setup>
import { ref, watch } from 'vue'
const count = ref(0)
watch(count, (newValue, oldValue) => {
console.log(`from ${oldValue} to ${newValue}`)
})
</script>
<!-- ============================================ -->
<!-- PART 2: WATCH A GETTER -->
<!-- ============================================ -->
<script setup>
const firstName = ref('Alice')
const lastName = ref('Smith')
watch(
() => `${firstName.value} ${lastName.value}`,
(newName) => console.log(`name: ${newName}`)
)
</script>
<!-- ============================================ -->
<!-- PART 3: WATCH MULTIPLE SOURCES -->
<!-- ============================================ -->
<script setup>
watch(
[firstName, lastName],
([newFirst, newLast]) => console.log(`${newFirst} ${newLast}`)
)
</script>
<!-- ============================================ -->
<!-- PART 4: IMMEDIATE -->
<!-- ============================================ -->
<script setup>
watch(count, (value) => {
console.log(`count: ${value}`)
}, { immediate: true })
</script>
<!-- ============================================ -->
<!-- PART 5: DEEP WATCH -->
<!-- ============================================ -->
<script setup>
const state = reactive({
user: { name: 'Alice', address: { city: 'Springfield' } }
})
watch(
() => state.user,
() => console.log('user changed'),
{ deep: true }
)
</script>
<!-- ============================================ -->
<!-- PART 6: CLEANUP WITH FETCH -->
<!-- ============================================ -->
<script setup>
const query = ref('')
watch(query, async (newQuery, oldQuery, onCleanup) => {
const controller = new AbortController()
onCleanup(() => controller.abort())
const res = await fetch(`/api/search?q=${newQuery}`, {
signal: controller.signal
})
console.log(await res.json())
})
</script>
<!-- ============================================ -->
<!-- PART 7: CLEANUP WITH TIMER -->
<!-- ============================================ -->
<script setup>
watch(source, (value, oldValue, onCleanup) => {
const id = setInterval(() => console.log(value), 1000)
onCleanup(() => clearInterval(id))
})
</script>
<!-- ============================================ -->
<!-- PART 8: WATCHEFFECT -->
<!-- ============================================ -->
<script setup>
import { ref, watchEffect } from 'vue'
const count = ref(0)
const doubled = ref(0)
watchEffect(() => {
doubled.value = count.value * 2
})
</script>
<!-- ============================================ -->
<!-- PART 9: STOP FUNCTION -->
<!-- ============================================ -->
<script setup>
const stop = watch(count, (value) => console.log(value))
// Later:
stop()
</script>
<!-- ============================================ -->
<!-- PART 10: FLUSH POST -->
<!-- ============================================ -->
<script setup>
watch(source, (value) => {
// DOM is updated
}, { flush: 'post' })
</script>
The ten parts covered the basic watch, watching a getter, watching multiple sources, the immediate option, the deep watch, the cleanup with the fetch, the cleanup with the timer, the watchEffect, the stop function, and the flush: 'post'.
Quick Reference
Watch Forms
| Form | Source |
|---|---|
watch(ref, cb) | A ref |
watch(() => x, cb) | A getter |
watch([a, b], cb) | Multiple sources |
watch(reactiveObj, cb) | A reactive object |
Watch Options
| Option | Effect |
|---|---|
immediate: true | Run on creation |
deep: true | Watch nested |
flush: 'pre' | Before DOM update |
flush: 'post' | After DOM update |
flush: 'sync' | Synchronously |
once: true | Run only once |
Callback Arguments
| Argument | Value |
|---|---|
newValue | The new value |
oldValue | The previous value |
onCleanup | Register the cleanup |
watch vs watchEffect
| Aspect | watch | watchEffect |
|---|---|---|
| Immediate | No | Yes |
| Dependencies | Explicit | Automatic |
| Old value | Yes | No |
| Use | Specific | Multiple |
Watch vs Computed
| Aspect | watch | computed |
|---|---|---|
| Returns | Stop function | Ref |
| Purpose | Side effect | Derived value |
| Lazy | Yes | Yes |
| Cached | No | Yes |
Best Practices
✅ Do This:
<!-- Use watch for the side effects -->
watch(count, (value) => {
localStorage.setItem('count', value)
}) // ✅
<!-- Use the cleanup for the async side effects -->
watch(query, async (q, old, onCleanup) => {
const controller = new AbortController()
onCleanup(() => controller.abort())
const res = await fetch(`/api?q=${q}`, { signal: controller.signal })
}) // ✅
<!-- Use the deep option for the nested changes -->
watch(() => state.user, callback, { deep: true }) // ✅
<!-- Use the immediate option for the initial run -->
watch(count, callback, { immediate: true }) // ✅
<!-- Use watchEffect for the multiple dependencies -->
watchEffect(() => {
console.log(`${a.value} ${b.value}`)
}) // ✅
<!-- Use the stop function when the watcher should stop -->
const stop = watch(count, callback)
stop() // ✅
❌ Don’t Do This:
<!-- Don't use watch for the derived value -->
watch(count, () => { doubled.value = count.value * 2 }) // ⚠️
// Use computed instead
<!-- Don't forget the cleanup for the async -->
watch(query, async (q) => {
const res = await fetch(`/api?q=${q}`) // no cleanup // ❌
})
<!-- Don't use the deep watch for the large objects -->
watch(bigObject, callback, { deep: true }) // performance // ⚠️
<!-- Don't assign to the watched source -->
watch(count, (value) => { count.value = value + 1 }) // ❌ loop
<!-- Don't forget the immediate option when the initial run is needed -->
watch(count, callback) // no initial run // ⚠️
<!-- Don't use watch when computed fits -->
watch([a, b], () => { total.value = a.value + b.value }) // ⚠️
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Watch not running | Lazy by default | Use immediate: true |
| Deep changes missed | No deep option | Add deep: true |
| Race condition | No cleanup | Use onCleanup |
| Infinite loop | Assigning to the source | Don’t assign |
| Watcher after unmount | No stop | The framework stops it |
watchEffect dependencies | Automatic | Check the reads |
| Old value same as new | The object reference | Use the getter |
Real-World Examples
1. localStorage Sync
watch(theme, (value) => {
localStorage.setItem('theme', value)
}, { immediate: true })
2. Debounced Search
watch(query, (value, old, onCleanup) => {
const id = setTimeout(() => search(value), 300)
onCleanup(() => clearTimeout(id))
})
3. Fetch with Abort
watch(userId, async (id, old, onCleanup) => {
const controller = new AbortController()
onCleanup(() => controller.abort())
const user = await fetchUser(id, { signal: controller.signal })
userData.value = user
})
4. Document Title
watchEffect(() => {
document.title = `${count.value} clicks`
})
5. Form Validation
watch([email, password], ([e, p]) => {
isValid.value = e.includes('@') && p.length >= 8
}, { immediate: true })
6. Deep Watch of Form
watch(form, (value) => {
saveDraft(value)
}, { deep: true })
7. Route Change
watch(() => route.params.id, (id) => {
loadData(id)
}, { immediate: true })
8. Animation Trigger
watch(isOpen, (value) => {
if (value) animateOpen()
else animateClose()
})
9. Multiple Sources
watch([firstName, lastName], ([first, last]) => {
fullName.value = `${first} ${last}`
})
10. Stop on Condition
const stop = watch(count, (value) => {
if (value >= 10) stop()
})
Visual
Watch Flow
┌─────────────────────────────────────────────────────────────┐
│ const count = ref(0) │
│ watch(count, (newVal, oldVal) => { }) │
│ │
│ count.value = 1 │
│ │ │
│ ▼ │
│ Watcher detects the change │
│ │ │
│ ▼ │
│ Run the cleanup (from the previous run) │
│ │ │
│ ▼ │
│ Run the callback (1, 0) │
│ │
│ The watcher is lazy: it does not run on the initial value. │
│ The immediate option changes that. │
│ │
└─────────────────────────────────────────────────────────────┘
Cleanup and Race Condition
┌─────────────────────────────────────────────────────────────┐
│ query.value = 'a' │
│ │ │
│ ▼ │
│ Fetch 'a' starts │
│ │ │
│ ▼ │
│ query.value = 'b' │
│ │ │
│ ▼ │
│ Cleanup runs ──▶ abort the 'a' fetch │
│ │ │
│ ▼ │
│ Fetch 'b' starts │
│ │ │
│ ▼ │
│ The 'a' response never arrives. │
│ │
└─────────────────────────────────────────────────────────────┘
Deep Watch
┌─────────────────────────────────────────────────────────────┐
│ const state = reactive({ │
│ user: { name: 'Alice', address: { city: 'Springfield' } }│
│ }) │
│ │
│ watch(() => state.user, callback, { deep: true }) │
│ │
│ state.user.name = 'Bob' │
│ │ │
│ ▼ │
│ Watcher runs (the nested change is detected) │
│ │
│ Without deep, the watcher only runs when the │
│ state.user reference changes. │
│ │
└─────────────────────────────────────────────────────────────┘
watch vs watchEffect
┌─────────────────────────────────────────────────────────────┐
│ WATCH │
│ │
│ watch(count, (newVal, oldVal) => { }) │
│ - Lazy: runs on the change │
│ - Explicit source │
│ - Receives the old value │
│ │
├─────────────────────────────────────────────────────────────┤
│ │
│ WATCHEFFECT │
│ │
│ watchEffect(() => { console.log(count.value) }) │
│ - Immediate: runs on the creation │
│ - Automatic dependencies │
│ - No old value │
│ │
└─────────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Function | watch(source, callback, options) |
| Source | Ref, getter, array, reactive |
| Callback args | newValue, oldValue, onCleanup |
immediate | Run on creation |
deep | Watch nested |
flush | pre, post, sync |
once | Run only once |
| Returns | Stop function |
watchEffect | Immediate, automatic deps |
| Cleanup | onCleanup, onWatcherCleanup |
| Auto-stop | On component unmount |
Key takeaways:
watch()is for the side effects. The computed derives a value, and the watch reacts to a change. The two are different tools. Use the computed for the derived state and the watch for the side effect.- The watch is lazy by default. The callback does not run on the initial value. The
immediate: trueoption makes it eager. The choice is the design decision. - The source can be a ref, a getter, an array, or a reactive object. The getter is for the derived value that is not a computed. The array is for the multiple sources. The reactive object is watched deeply by default.
- The callback receives the new value, the old value, and the
onCleanup. TheonCleanupregisters the cleanup that runs before the next invocation and when the watcher stops. The pattern is the race condition prevention. - The
deep: trueoption watches the nested properties. The watcher runs when the nested value changes, even though the reference is the same. The performance cost is the traversal of the object. - The
watchEffect()is the alternative. It runs immediately and tracks the dependencies automatically. It is for the side effect that reads the multiple reactive values and does not need the old value. - The watcher is automatically stopped when the component unmounts. The cleanup runs, and the resources are released. The explicit
stop()is for the watchers that should stop earlier.
Remember: The watch is the side effect and the computed is the value. Use the watch for the data fetching, the localStorage sync, the log, the animation, and the imperative API call. Use the cleanup for the async side effects, and the immediate option for the initial run. The watchEffect is for the multiple dependencies and the automatic tracking. And remember the difference: the computed is the derived value, the watch is the reaction to the change. The value is what the template renders, and the reaction is what the application does.
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!