Vue.js 25 🟢 Cleaning Up Side Effects with onWatcherCleanup()
A watcher runs a side effect. The side effect may be asynchronous: a fetch that is in flight, a timer that is set, a subscription that is created. When the watched source changes before the side effect completes, the old side effect should be cancelled. The cleanup is the mechanism. The watch() and the watchEffect() callbacks receive the onCleanup function as an argument, and the onWatcherCleanup() is the newer API that registers the cleanup from inside the callback. The cleanup runs before the next invocation of the watcher and when the watcher is stopped.
Key point: The cleanup function is registered with the onCleanup argument of the callback or with the onWatcherCleanup() function. It runs before the next run of the watcher and when the watcher is stopped. The cleanup is the place for the abort controller, the timer clear, the subscription unsubscribe, and the event listener removal. Without the cleanup, the async side effects race: the old response can arrive after the new one and set the stale state.
Why the cleanup exists
The race condition problem. A watcher that fetches on the query change starts a request for the old query and a request for the new query. The two requests are in flight. The old request can resolve after the new one, and the old response overwrites the new data. The cleanup aborts the old request, and the race cannot happen.
The resource leak problem. A watcher that sets the interval, the event listener, or the subscription creates the resources. When the watcher re-runs, the old resources are still active. The cleanup releases them. Without it, the resources accumulate, and the memory and the CPU are wasted.
The lifecycle problem. The watcher stops when the component unmounts. The cleanup runs, and the resources are released. Without the cleanup, the resources outlive the component, and the callbacks fire on the unmounted component.
The async ordering problem. The cleanup runs before the next invocation. The order is deterministic: the cleanup of the previous run, then the callback of the new run. The cleanup is the guarantee that the old side effect is finished before the new one starts.
The manual problem. The cleanup can be implemented manually with the flags and the checks. The let cancelled = false and the if (!cancelled) setData(data) is the pattern. The onWatcherCleanup is the framework mechanism that replaces the manual flag.
a. The onCleanup argument
The watcher callback receives the onCleanup function as the third argument (for the watch) or the first argument (for the watchEffect).
<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. The controller.abort() cancels the fetch. The pattern is the race condition prevention.
The watchEffect callback receives the onCleanup as the first argument.
<script setup>
import { ref, watchEffect } from 'vue'
const query = ref('')
watchEffect((onCleanup) => {
const controller = new AbortController()
onCleanup(() => controller.abort())
fetch(`/api/search?q=${query.value}`, {
signal: controller.signal
})
})
</script>
The callback reads the query, and the query is tracked. The cleanup runs when the query changes. The pattern is the same as the watch, and the watchEffect provides the onCleanup without the explicit source.
The cleanup can register the multiple functions.
<script setup>
watch(source, (value, oldValue, onCleanup) => {
const controller = new AbortController()
const timer = setTimeout(() => {}, 1000)
onCleanup(() => {
controller.abort()
clearTimeout(timer)
})
})
</script>
The cleanup releases the multiple resources. The order of the registration is the order of the execution.
b. The onWatcherCleanup function
The onWatcherCleanup() is the standalone function that registers the cleanup. It was added in Vue 3.5.
<script setup>
import { watch, onWatcherCleanup } from 'vue'
watch(count, (value) => {
const controller = new AbortController()
onWatcherCleanup(() => controller.abort())
fetch(`/api/data/${value}`, {
signal: controller.signal
})
})
</script>
The onWatcherCleanup is called inside the watcher callback. It registers the function that runs on the next invocation. The pattern is the same as the onCleanup argument, and the function is the newer API.
The onWatcherCleanup must be called during the synchronous execution of the watcher callback. It cannot be called after an await.
<script setup>
watch(count, async (value) => {
const controller = new AbortController()
onWatcherCleanup(() => controller.abort()) // OK
await someAsyncOperation()
onWatcherCleanup(() => {}) // WARNING: too late
})
</script>
The onWatcherCleanup must be registered before the first await. The reason is the internal tracking: the function is associated with the current watcher instance during the synchronous execution.
The onWatcherCleanup works in the watch and the watchEffect.
<script setup>
watchEffect(() => {
const controller = new AbortController()
onWatcherCleanup(() => controller.abort())
fetch(`/api?q=${query.value}`, { signal: controller.signal })
})
</script>
The function is called during the effect execution. The cleanup runs when the effect re-runs or stops.
The onWatcherCleanup can be called multiple times.
<script setup>
watch(source, () => {
onWatcherCleanup(() => console.log('cleanup 1'))
onWatcherCleanup(() => console.log('cleanup 2'))
})
</script>
The multiple cleanups run in the order of the registration. The pattern is for the multiple resources that need the release.
The difference between the onCleanup and the onWatcherCleanup.
| Aspect | onCleanup | onWatcherCleanup |
|---|---|---|
| API | Callback argument | Standalone function |
| Version | 3.0+ | 3.5+ |
| Usage | (onCleanup) => {} | onWatcherCleanup(() => {}) |
| Tracking | Implicit | Implicit |
| Order | The order of the argument | The order of the calls |
The two are equivalent in the behavior. The onWatcherCleanup is the newer API and the one that the composition functions can use without the argument passing.
c. The cleanup and the composition functions
The onWatcherCleanup is the mechanism for the composables that need to register the cleanup without the callback argument.
// composables/useFetch.js
import { ref, watchEffect, onWatcherCleanup } from 'vue'
export function useFetch(url) {
const data = ref(null)
const error = ref(null)
watchEffect(() => {
const controller = new AbortController()
onWatcherCleanup(() => controller.abort())
fetch(url.value, { signal: controller.signal })
.then((r) => r.json())
.then((d) => { data.value = d })
.catch((e) => {
if (e.name !== 'AbortError') error.value = e
})
})
return { data, error }
}
The composable registers the cleanup with the onWatcherCleanup. The cleanup is associated with the current effect, and the url is the tracked dependency. The composable does not need the onCleanup argument.
The onWatcherCleanup is used in the watch inside the composable.
import { ref, watch, onWatcherCleanup } from 'vue'
export function useDebouncedSearch(delay) {
const query = ref('')
const results = ref([])
watch(query, (value) => {
const id = setTimeout(() => {
fetch(`/api?q=${value}`).then((r) => r.json()).then((d) => {
results.value = d
})
}, delay)
onWatcherCleanup(() => clearTimeout(id))
})
return { query, results }
}
The query is the returned ref. The watch runs on the query change. The setTimeout is the debounce. The onWatcherCleanup clears the timer. The composable encapsulates the debounce and the cleanup.
The cleanup and the onMounted/onUnmounted.
<script setup>
import { onMounted, onUnmounted } from 'vue'
let interval
onMounted(() => {
interval = setInterval(() => {}, 1000)
})
onUnmounted(() => {
clearInterval(interval)
})
</script>
The onMounted and the onUnmounted are the lifecycle hooks. The onWatcherCleanup is the watcher-specific cleanup. The two are different: the onUnmounted runs when the component unmounts, and the onWatcherCleanup runs before the next watcher run and when the watcher stops.
The cleanup and the watch with the immediate option.
<script setup>
watch(count, (value, oldValue, onCleanup) => {
const controller = new AbortController()
onCleanup(() => controller.abort())
fetch(`/api/data/${value}`, { signal: controller.signal })
}, { immediate: true })
</script>
The immediate run registers the cleanup. The cleanup runs before the next run. The first cleanup runs when the count changes for the first time.
Complete Example Session
<!-- ============================================ -->
<!-- PART 1: ONCLEANUP ARGUMENT WITH WATCH -->
<!-- ============================================ -->
<script setup>
import { ref, watch } from 'vue'
const query = ref('')
watch(query, async (value, oldValue, onCleanup) => {
const controller = new AbortController()
onCleanup(() => controller.abort())
const res = await fetch(`/api?q=${value}`, { signal: controller.signal })
console.log(await res.json())
})
</script>
<!-- ============================================ -->
<!-- PART 2: ONCLEANUP WITH WATCHEFFECT -->
<!-- ============================================ -->
<script setup>
watchEffect((onCleanup) => {
const controller = new AbortController()
onCleanup(() => controller.abort())
fetch(`/api?q=${query.value}`, { signal: controller.signal })
})
</script>
<!-- ============================================ -->
<!-- PART 3: ONWATCHERCLEANUP -->
<!-- ============================================ -->
<script setup>
import { watch, onWatcherCleanup } from 'vue'
watch(count, (value) => {
const controller = new AbortController()
onWatcherCleanup(() => controller.abort())
fetch(`/api/data/${value}`, { signal: controller.signal })
})
</script>
<!-- ============================================ -->
<!-- PART 4: CLEANUP BEFORE AWAIT -->
<!-- ============================================ -->
<script setup>
watch(count, async (value) => {
const controller = new AbortController()
onWatcherCleanup(() => controller.abort()) // before await
await someAsyncOperation()
})
</script>
<!-- ============================================ -->
<!-- PART 5: MULTIPLE CLEANUPS -->
<!-- ============================================ -->
<script setup>
watch(source, () => {
onWatcherCleanup(() => console.log('cleanup 1'))
onWatcherCleanup(() => console.log('cleanup 2'))
})
</script>
<!-- ============================================ -->
<!-- PART 6: TIMER CLEANUP -->
<!-- ============================================ -->
<script setup>
watch(query, (value) => {
const id = setTimeout(() => search(value), 300)
onWatcherCleanup(() => clearTimeout(id))
})
</script>
<!-- ============================================ -->
<!-- PART 7: COMPOSABLE WITH CLEANUP -->
<!-- ============================================ -->
<script setup>
import { useDebouncedSearch } from './composables/useDebouncedSearch'
const { query, results } = useDebouncedSearch(300)
</script>
<!-- ============================================ -->
<!-- PART 8: ONUNMOUNTED VS ONWATCHERCLEANUP -->
<!-- ============================================ -->
<script setup>
import { onUnmounted } from 'vue'
let interval
onMounted(() => {
interval = setInterval(() => {}, 1000)
})
onUnmounted(() => clearInterval(interval))
// onWatcherCleanup is the watcher-specific cleanup
</script>
<!-- ============================================ -->
<!-- PART 9: IMMEDIATE WITH CLEANUP -->
<!-- ============================================ -->
<script setup>
watch(count, (value, oldValue, onCleanup) => {
const controller = new AbortController()
onCleanup(() => controller.abort())
fetch(`/api/${value}`, { signal: controller.signal })
}, { immediate: true })
</script>
<!-- ============================================ -->
<!-- PART 10: COMPOSABLE WITH WATCH AND CLEANUP -->
<!-- ============================================ -->
<script setup>
// useFetch(url) with the onWatcherCleanup
const { data, error } = useFetch(url)
</script>
The ten parts covered the onCleanup with the watch, the onCleanup with the watchEffect, the onWatcherCleanup, the cleanup before the await, the multiple cleanups, the timer cleanup, the composable with the cleanup, the onUnmounted vs the onWatcherCleanup, the immediate with the cleanup, and the composable with the watch and the cleanup.
Quick Reference
Cleanup APIs
| API | Version | Usage |
|---|---|---|
onCleanup argument | 3.0+ | (value, old, onCleanup) => {} |
onWatcherCleanup | 3.5+ | onWatcherCleanup(() => {}) |
When Cleanup Runs
| Event | Cleanup Runs |
|---|---|
| Watcher re-runs | Yes, before |
| Watcher stops | Yes |
| Component unmounts | Yes (auto-stop) |
Cleanup Uses
| Resource | Cleanup |
|---|---|
| Fetch | controller.abort() |
| Timer | clearTimeout(id) |
| Interval | clearInterval(id) |
| Subscription | unsubscribe() |
| Event listener | removeEventListener |
onWatcherCleanup Rules
| Rule | Requirement |
|---|---|
| Call timing | Synchronous, before await |
| Multiple calls | Allowed |
| Order | Registration order |
| Context | Inside the watcher callback |
onUnmounted vs onWatcherCleanup
| Aspect | onUnmounted | onWatcherCleanup |
|---|---|---|
| Runs | On unmount | On watcher re-run and stop |
| Scope | Component | Watcher |
| Use | Lifecycle resources | Watcher resources |
Best Practices
✅ Do This:
<!-- Use onCleanup for the fetch -->
watch(query, async (value, old, onCleanup) => {
const controller = new AbortController()
onCleanup(() => controller.abort())
const res = await fetch(`/api?q=${value}`, { signal: controller.signal })
}) // ✅
<!-- Use onWatcherCleanup in the composables -->
watchEffect(() => {
const controller = new AbortController()
onWatcherCleanup(() => controller.abort())
fetch(url.value, { signal: controller.signal })
}) // ✅
<!-- Register the cleanup before the await -->
watch(count, async (value) => {
const controller = new AbortController()
onWatcherCleanup(() => controller.abort()) // before await
await someAsync()
}) // ✅
<!-- Use the cleanup for the timer -->
watch(query, (value) => {
const id = setTimeout(() => search(value), 300)
onWatcherCleanup(() => clearTimeout(id))
}) // ✅
<!-- Use the cleanup in the composables -->
export function useFetch(url) {
watchEffect(() => {
const controller = new AbortController()
onWatcherCleanup(() => controller.abort())
// ...
})
} // ✅
❌ Don’t Do This:
<!-- Don't forget the cleanup for the async -->
watch(query, async (value) => {
const res = await fetch(`/api?q=${value}`) // no cleanup // ❌
})
<!-- Don't register the cleanup after the await -->
watch(count, async (value) => {
await someAsync()
onWatcherCleanup(() => {}) // too late // ❌
})
<!-- Don't use onUnmounted for the watcher resources -->
onUnmounted(() => controller.abort()) // wrong scope // ⚠️
<!-- Don't forget the cleanup for the timer -->
watch(query, (value) => {
setTimeout(() => search(value), 300) // no cleanup // ❌
})
<!-- Don't create the resources without the release -->
watch(source, () => {
const ws = new WebSocket(url) // no close // ❌
})
<!-- Don't rely on the manual cancelled flag when the cleanup exists -->
let cancelled = false // the framework has the cleanup // ⚠️
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Race condition | No cleanup | onCleanup |
| Timer leak | No clearTimeout | onWatcherCleanup |
| Cleanup after await | Too late | Register before |
| Wrong scope | Used onUnmounted | Use onWatcherCleanup |
| Resource leak | No unsubscribe | Add the cleanup |
| Stale data | Old response wins | Abort the old request |
Real-World Examples
1. Fetch with Abort
watch(query, async (value, old, onCleanup) => {
const controller = new AbortController()
onCleanup(() => controller.abort())
const res = await fetch(`/api?q=${value}`, { signal: controller.signal })
})
2. Debounced Search
watch(query, (value) => {
const id = setTimeout(() => search(value), 300)
onWatcherCleanup(() => clearTimeout(id))
})
3. WebSocket
watch(url, (value) => {
const ws = new WebSocket(value)
onWatcherCleanup(() => ws.close())
})
4. Event Listener
watch(target, (value) => {
const handler = () => {}
value.addEventListener('resize', handler)
onWatcherCleanup(() => value.removeEventListener('resize', handler))
})
5. Interval
watch(interval, (value) => {
const id = setInterval(() => {}, value)
onWatcherCleanup(() => clearInterval(id))
})
6. Subscription
watch(source, (value) => {
const sub = value.subscribe(handle)
onWatcherCleanup(() => sub.unsubscribe())
})
7. Composable useFetch
export function useFetch(url) {
const data = ref(null)
watchEffect(() => {
const controller = new AbortController()
onWatcherCleanup(() => controller.abort())
fetch(url.value, { signal: controller.signal })
.then(r => r.json())
.then(d => data.value = d)
})
return { data }
}
8. Multiple Cleanups
watch(source, () => {
onWatcherCleanup(() => cleanup1())
onWatcherCleanup(() => cleanup2())
})
9. Immediate with Cleanup
watch(count, (value, old, onCleanup) => {
const controller = new AbortController()
onCleanup(() => controller.abort())
fetch(`/api/${value}`, { signal: controller.signal })
}, { immediate: true })
10. Animation
watch(isOpen, (value) => {
const animation = animate(value)
onWatcherCleanup(() => animation.cancel())
})
Visual
Cleanup Lifecycle
┌─────────────────────────────────────────────────────────────┐
│ watch(query, callback) │
│ │
│ query = 'a' │
│ │ │
│ ▼ │
│ Callback runs │
│ │ │
│ ├── Register the cleanup │
│ │ │
│ └── Start the fetch │
│ │
│ query = 'b' │
│ │ │
│ ▼ │
│ Cleanup runs (abort the 'a' fetch) │
│ │ │
│ ▼ │
│ Callback runs again (fetch 'b') │
│ │ │
│ ▼ │
│ Component unmounts │
│ │ │
│ ▼ │
│ Cleanup runs (abort the 'b' fetch) │
│ │
└─────────────────────────────────────────────────────────────┘
Race Condition Prevention
┌─────────────────────────────────────────────────────────────┐
│ WITHOUT CLEANUP │
│ │
│ query = 'a' ──▶ fetch 'a' starts │
│ query = 'b' ──▶ fetch 'b' starts │
│ 'b' resolves ──▶ setData(b) │
│ 'a' resolves ──▶ setData(a) ← WRONG (stale) │
│ │
├─────────────────────────────────────────────────────────────┤
│ │
│ WITH CLEANUP │
│ │
│ query = 'a' ──▶ fetch 'a' starts │
│ query = 'b' ──▶ cleanup aborts 'a' ──▶ fetch 'b' starts │
│ 'b' resolves ──▶ setData(b) ← correct │
│ 'a' aborted ──▶ no response │
│ │
└─────────────────────────────────────────────────────────────┘
onCleanup vs onWatcherCleanup
┌─────────────────────────────────────────────────────────────┐
│ ONCLEANUP ARGUMENT │
│ │
│ watch(query, (value, old, onCleanup) => { │
│ onCleanup(() => {}) │
│ }) │
│ │
│ The cleanup is passed as the argument. │
│ Vue 3.0+. │
│ │
├─────────────────────────────────────────────────────────────┤
│ │
│ ONWATCHERCLEANUP │
│ │
│ watch(query, (value) => { │
│ onWatcherCleanup(() => {}) │
│ }) │
│ │
│ The cleanup is imported and called. │
│ Vue 3.5+. │
│ Useful in the composables. │
│ │
└─────────────────────────────────────────────────────────────┘
Cleanup Order
┌─────────────────────────────────────────────────────────────┐
│ watch(source, (value, old, onCleanup) => { │
│ onCleanup(() => console.log('cleanup 1')) │
│ onCleanup(() => console.log('cleanup 2')) │
│ }) │
│ │
│ On the next run: │
│ cleanup 1 │
│ cleanup 2 │
│ (then the callback runs) │
│ │
│ The cleanups run in the registration order. │
│ │
└─────────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
onCleanup argument | Third arg of watch, first of watchEffect |
onWatcherCleanup | Standalone function, Vue 3.5+ |
| Runs | Before next run, on stop |
| Use | Fetch abort, timer clear, unsubscribe |
| Multiple | Allowed, registration order |
| Timing | Synchronous, before await |
| Auto-stop | On component unmount |
Key takeaways:
- The cleanup runs before the next watcher invocation and when the watcher stops. It is the mechanism that releases the resources the previous run created. The watcher without the cleanup leaks the resources and races the async side effects.
- The
onCleanupargument is the original API. Thewatchcallback receives it as the third argument, and thewatchEffectcallback receives it as the first. The function registers the cleanup. - The
onWatcherCleanupis the newer API. It was added in Vue 3.5, and it is the standalone function imported from Vue. It registers the cleanup from inside the callback, and it is useful in the composables that do not have the callback argument to pass. - The cleanup must be registered before the first
await. The function is associated with the current watcher instance during the synchronous execution. Calling it after anawaitis too late, and the framework warns. - The multiple cleanups run in the registration order. The order is the order of the calls, and the pattern is for the multiple resources.
- The
onUnmountedis different from theonWatcherCleanup. TheonUnmountedruns when the component unmounts. TheonWatcherCleanupruns before the next watcher run and when the watcher stops. The watcher-specific resources use theonWatcherCleanup. - The manual cancelled flag is no longer needed. The
onCleanupand theonWatcherCleanupare the framework mechanism that replaces the manual flag and the check.
Remember: The cleanup is the mechanism that prevents the race conditions and the resource leaks. The onCleanup argument is the original API, and the onWatcherCleanup is the newer one. The cleanup runs before the next run and when the watcher stops. Register it before the await, release the resources, and let the framework handle the lifecycle. The watcher without the cleanup is the watcher that leaks. The watcher with the cleanup is the watcher that is correct.
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!