Composables and Lifecycle
Composables can register lifecycle hooks, and effect scopes decide when their watchers and cleanups run.
onScopeDispose(fn: () => void, failSilently?: boolean): voidLifecycle hooks are not restricted to components. onMounted, onUnmounted and the rest can be called inside a composable, where they register against whichever component is setting up at the time. That is what lets a composable own a listener, an interval or an observer end to end: the component calls the function, and the teardown is already written.
Scopes, not only components
Every setup runs inside an effect scope — an object that collects the computed values and watchers created in it so they can be stopped together. When the component unmounts, its scope stops and those effects stop with it. onScopeDispose(fn) registers a callback on the current scope. For a composable that is the better cleanup hook: it fires on unmount like onUnmounted, and it also fires when the composable was run inside a scope you created yourself.
Your own scope
effectScope() returns an object with run and stop. Effects created inside scope.run(fn) belong to that scope, and scope.stop() disposes them all. Pass true for a detached scope, which no parent scope will stop for you — the shape you need for state that must outlive any single component.
What trips people up
Registration needs an active instance or scope. Call a composable from a click handler and onMounted never fires, while onScopeDispose warns unless you pass true as its second argument, which Vue 3.5 added for that case.
Example
<script setup lang="ts">
import { computed } from 'vue'
import { useTicker } from '../composables/useTicker'
const { ticks } = useTicker(1000)
const label = computed(() => `${ticks.value}s on this page`)
</script>
<template>
<p aria-live="polite">{{ label }}</p>
</template>
<style scoped>
p {
font-variant-numeric: tabular-nums;
}
</style>When to use it
- Own an interval, observer or subscription inside a composable so the component cannot forget to stop it.
- Read window or element measurements once the component is in the document.
- Keep module-level reactive state alive across unmounts and dispose it on your own terms.
More examples
Cleanup with onScopeDispose
The interval starts when the composable is called and is cleared when the owning scope stops, which for a component means unmount. Using `onScopeDispose` rather than `onUnmounted` means the same composable still cleans up correctly if you later run it inside an `effectScope` of your own.
// src/composables/useTicker.ts
import { onScopeDispose, ref } from 'vue'
export function useTicker(intervalMs = 1000) {
const ticks = ref(0)
const id = setInterval(() => {
ticks.value += 1
}, intervalMs)
onScopeDispose(() => clearInterval(id))
return { ticks }
}When the work has to wait for the DOM
The refs start at 0 and are filled in `onMounted`, because that is the first point at which the component is in the document. Pairing `onMounted` with `onUnmounted` in one file keeps the add and the remove visible together, which is how listeners stop leaking.
// src/composables/useWindowSize.ts
import { onMounted, onUnmounted, ref } from 'vue'
export function useWindowSize() {
const width = ref(0)
const height = ref(0)
function update() {
width.value = window.innerWidth
height.value = window.innerHeight
}
onMounted(() => {
update()
window.addEventListener('resize', update)
})
onUnmounted(() => window.removeEventListener('resize', update))
return { width, height }
}A detached scope for state outside components
The watcher here belongs to a scope you own, not to a component, so it survives every unmount until `disposeFilter()` runs. `scope.run` is typed as returning `T | undefined` because an already-stopped scope runs nothing, which is why the non-null assertion is there. The logged 0 is not a bug: watchers flush after the current tick, so the value updates later.
// src/lib/sharedFilter.ts
import { effectScope, ref, watch } from 'vue'
const scope = effectScope(true)
export const filter = scope.run(() => {
const query = ref('')
const matches = ref(0)
watch(query, (value) => {
matches.value = value.trim().length
})
return { query, matches }
})!
export function disposeFilter() {
scope.stop()
}
filter.query.value = ' red '
console.log(filter.matches.value)
// Output: 0
Discussion