| |

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.

AspectonCleanuponWatcherCleanup
APICallback argumentStandalone function
Version3.0+3.5+
Usage(onCleanup) => {}onWatcherCleanup(() => {})
TrackingImplicitImplicit
OrderThe order of the argumentThe 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

APIVersionUsage
onCleanup argument3.0+(value, old, onCleanup) => {}
onWatcherCleanup3.5+onWatcherCleanup(() => {})

When Cleanup Runs

EventCleanup Runs
Watcher re-runsYes, before
Watcher stopsYes
Component unmountsYes (auto-stop)

Cleanup Uses

ResourceCleanup
Fetchcontroller.abort()
TimerclearTimeout(id)
IntervalclearInterval(id)
Subscriptionunsubscribe()
Event listenerremoveEventListener

onWatcherCleanup Rules

RuleRequirement
Call timingSynchronous, before await
Multiple callsAllowed
OrderRegistration order
ContextInside the watcher callback

onUnmounted vs onWatcherCleanup

AspectonUnmountedonWatcherCleanup
RunsOn unmountOn watcher re-run and stop
ScopeComponentWatcher
UseLifecycle resourcesWatcher 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

PitfallWhy It HappensFix
Race conditionNo cleanuponCleanup
Timer leakNo clearTimeoutonWatcherCleanup
Cleanup after awaitToo lateRegister before
Wrong scopeUsed onUnmountedUse onWatcherCleanup
Resource leakNo unsubscribeAdd the cleanup
Stale dataOld response winsAbort 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

ItemValue
onCleanup argumentThird arg of watch, first of watchEffect
onWatcherCleanupStandalone function, Vue 3.5+
RunsBefore next run, on stop
UseFetch abort, timer clear, unsubscribe
MultipleAllowed, registration order
TimingSynchronous, before await
Auto-stopOn 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 onCleanup argument is the original API. The watch callback receives it as the third argument, and the watchEffect callback receives it as the first. The function registers the cleanup.
  • The onWatcherCleanup is 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 an await is 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 onUnmounted is different from the onWatcherCleanup. The onUnmounted runs when the component unmounts. The onWatcherCleanup runs before the next watcher run and when the watcher stops. The watcher-specific resources use the onWatcherCleanup.
  • The manual cancelled flag is no longer needed. The onCleanup and the onWatcherCleanup are 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!