Cleaning Up

Listeners, timers, observers and in-flight requests outlive the component unless you stop them when it unmounts.

SyntaxonUnmounted(() => { controller.abort(); window.clearInterval(timer) })

A component's own template is cleaned up for you. Everything it started outside that template is not. An interval, a listener on window or document, an IntersectionObserver, a subscription, an in-flight request: each one still holds a reference to this component's state after the component has left the screen.

Why the bug shows up elsewhere

A leak rarely breaks the component that leaked, because that component is gone. It breaks whatever came next. Mount and unmount a dialog three times and one Escape key closes three dialogs, because three listeners are still attached. A late response overwrites the data of the view you navigated to. An interval keeps recomputing against detached state, so memory and CPU climb the longer the session runs. That distance between cause and symptom is what makes these bugs expensive.

Pair every start with a stop

Start in onMounted, stop in onUnmounted. removeEventListener needs the identical function reference that was added, so name the handler instead of passing an inline arrow. An AbortController covers both shapes at once: pass controller.signal to fetch and as the signal option of addEventListener, then a single controller.abort() cancels the request and detaches the listeners. Timers still need clearInterval or clearTimeout, and observers need disconnect().

Inside a watcher, onWatcherCleanup() (Vue 3.5) registers teardown that runs before the watcher's next run and when the component unmounts. Watchers created synchronously in <script setup> stop themselves on unmount; one created inside a setTimeout does not.

Example

Example · vue
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue'

const width = ref(0)
const controller = new AbortController()
let timer: number | undefined

function measure() {
  width.value = window.innerWidth
}

onMounted(() => {
  measure()
  window.addEventListener('resize', measure, { signal: controller.signal })
  timer = window.setInterval(measure, 5000)
})

onUnmounted(() => {
  controller.abort()
  window.clearInterval(timer)
})
</script>

<template>
  <p class="width">Viewport is {{ width }}px wide</p>
</template>

<style scoped>
.width {
  font-variant-numeric: tabular-nums;
}
</style>

When to use it

  • Remove a window resize or document keydown listener when a panel closes.
  • Abort an in-flight request when the user navigates away mid-load.
  • Disconnect a ResizeObserver or IntersectionObserver on unmount.
  • Clear a polling interval so it stops firing against a screen nobody is looking at.

More examples

Aborting a stale request from a watcher

onWatcherCleanup arrived in Vue 3.5. It registers teardown for this run of the watcher, so when userId changes again, or the component unmounts, the previous request is aborted before the new one starts. The equivalent longhand, which works in every Vue 3 version, is the third argument of the watch callback: watch(src, (id, oldId, onCleanup) => { onCleanup(() => controller.abort()) }). Two rules matter: onWatcherCleanup must be called before the first await, and an aborted fetch rejects with an AbortError that you have to swallow deliberately.

Example · vue
<script setup lang="ts">
import { onWatcherCleanup, ref, watch } from 'vue'

const props = defineProps<{ userId: number }>()
const name = ref('')

watch(
  () => props.userId,
  async (id) => {
    const controller = new AbortController()
    onWatcherCleanup(() => controller.abort())

    try {
      const response = await fetch(`/api/users/${id}`, {
        signal: controller.signal
      })
      const user: { name: string } = await response.json()
      name.value = user.name
    } catch (err) {
      if (!(err instanceof DOMException && err.name === 'AbortError')) {
        throw err
      }
    }
  },
  { immediate: true }
)
</script>

<template>
  <p>{{ name === '' ? 'Loading' : name }}</p>
</template>

<style scoped>
p {
  margin: 0;
}
</style>

A keyboard listener on document

Listeners on an element inside the component's own template disappear with that element. Listeners on document, window or any object outside the tree do not, which is why this pair of hooks exists. The handler is a named function so that removeEventListener can be given the same reference; document.removeEventListener('keydown', (e) => ...) looks right and removes nothing. The symptom of getting it wrong is a close event firing once per time the dialog has ever been opened.

Example · vue
<script setup lang="ts">
import { onMounted, onUnmounted } from 'vue'

const emit = defineEmits<{ close: [] }>()

function onKeydown(event: KeyboardEvent) {
  if (event.key === 'Escape') {
    emit('close')
  }
}

onMounted(() => {
  document.addEventListener('keydown', onKeydown)
})

onUnmounted(() => {
  document.removeEventListener('keydown', onKeydown)
})
</script>

<template>
  <div class="dialog" role="dialog" aria-modal="true">
    <slot />
    <button type="button" @click="emit('close')">Close</button>
  </div>
</template>

<style scoped>
.dialog {
  border: 1px solid #ccc;
  padding: 1rem;
}
</style>

A watcher Vue does not own

A watch or watchEffect created synchronously in script setup belongs to the component and stops on unmount. One created inside a setTimeout, a promise callback or after an await does not, and keeps running — and keeps the component's state alive — until you stop it. Both handles are needed here: clearTimeout in case the component unmounts before the watcher is even created, and the stop function that watchEffect returns in case it already was. If you find yourself writing this, prefer creating the watcher synchronously.

Example · vue
<script setup lang="ts">
import { onUnmounted, ref, watchEffect } from 'vue'

const query = ref('')
let stop: (() => void) | undefined

// Created asynchronously, so this watcher is not bound to the component.
const timer = window.setTimeout(() => {
  stop = watchEffect(() => {
    document.title = query.value === '' ? 'Search' : `Search: ${query.value}`
  })
}, 1000)

onUnmounted(() => {
  window.clearTimeout(timer)
  stop?.()
  document.title = 'Search'
})
</script>

<template>
  <input v-model="query" type="search" placeholder="Search" />
</template>

<style scoped>
input {
  width: 12rem;
}
</style>

Discussion

  • Be the first to comment on this lesson.