Composables and Lifecycle

Composables can register lifecycle hooks, and effect scopes decide when their watchers and cleanups run.

SyntaxonScopeDispose(fn: () => void, failSilently?: boolean): void

Lifecycle 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

Example · vue
<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.

Example · ts
// 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.

Example · ts
// 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.

Example · ts
// 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

  • Be the first to comment on this lesson.