effectScope

Collect computeds and watchers into one scope so a single stop() disposes of all of them.

Syntaxconst scope = effectScope(detached?); scope.run(fn); scope.stop()

Inside a component, Vue disposes of your effects for you: every computed and watch created during setup is stopped when the component unmounts. That cleanup is not special-cased for components. It is an effect scope, and effectScope() gives you one of your own.

The API

effectScope(detached?) returns an object with run(fn) and stop(). Effects created synchronously inside run belong to the scope, and stop() disposes of all of them at once. run returns whatever fn returned, or undefined if the scope has already been stopped.

A new scope attaches itself to whatever scope is active at creation time, so a scope created during setup is stopped with the component. Pass true for detached to opt out of that and own the lifetime yourself.

Cleanup without a component

onScopeDispose(fn) registers a callback on the active scope, whichever kind it is. That is the difference from onUnmounted, which requires a component instance: a composable that calls onScopeDispose works inside a component and inside a standalone scope. getCurrentScope() tells you whether there is a scope at all; since Vue 3.5 you can instead pass onScopeDispose(fn, true) to skip the warning when there is none.

This is what makes shared state outside a component possible: create it once in a detached scope, and keep one stop() that tears it all down.

Example

Example · vue
<script setup lang="ts">
import { effectScope, ref, watch, onUnmounted, type EffectScope } from 'vue'

const pointerX = ref(0)
const tracking = ref(false)
let scope: EffectScope | undefined

function start(): void {
  if (scope) return
  // Created in an event handler, so no scope is active to adopt this one:
  // its lifetime is ours.
  scope = effectScope()
  scope.run(() => {
    watch(pointerX, (x: number) => {
      console.log('pointer moved to', x)
    })
    watch(pointerX, (x: number) => {
      console.log('also logged', x)
    })
  })
  tracking.value = true
}

function stop(): void {
  scope?.stop() // disposes of every watcher created inside run()
  scope = undefined
  tracking.value = false
}

onUnmounted(stop)
</script>

<template>
  <p>Tracking: {{ tracking ? 'on' : 'off' }}</p>
  <input v-model.number="pointerX" type="number" />
  <button :disabled="tracking" @click="start">Start</button>
  <button :disabled="!tracking" @click="stop">Stop</button>
</template>

<style scoped>
button {
  margin-right: 0.5rem;
}
</style>

When to use it

  • Starting a group of watchers when a panel opens and disposing of them when it closes
  • Creating application-wide shared state that outlives every component using it
  • Writing a composable whose cleanup works outside a component too
  • Disposing of computeds and watchers between unit tests

More examples

Shared state in a detached scope

Every component that calls useSharedTimer gets the same refs, and the interval and the computed exist once. This is the pattern a store library uses internally, and the one to copy when a composable has to keep working while no single component owns it.

Example · ts
// src/composables/useSharedTimer.ts
import { computed, effectScope, onScopeDispose, ref } from 'vue'

// Detached: no component owns this, so nothing stops it behind our backs.
const scope = effectScope(true)

const api = scope.run(() => {
  const ticks = ref(0)
  const id = setInterval(() => {
    ticks.value += 1
  }, 1000)
  onScopeDispose(() => clearInterval(id))

  const minutes = computed(() => Math.floor(ticks.value / 60))
  return { ticks, minutes }
})!

export function useSharedTimer() {
  return api
}

export function disposeSharedTimer(): void {
  // Stops the computed and runs the dispose callback that clears the interval.
  scope.stop()
}

A composable that cleans up in any scope

Writing cleanup against the scope rather than the component is what lets the same composable be used in setup, in a detached scope, and in a plain test with a scope wrapped around it. Returning cancel as well gives the caller a manual escape.

Example · ts
// src/composables/useInterval.ts
import { getCurrentScope, onScopeDispose } from 'vue'

export function useInterval(callback: () => void, ms: number): () => void {
  const id = setInterval(callback, ms)
  const cancel = (): void => clearInterval(id)

  // onUnmounted would need a component instance. onScopeDispose takes
  // whatever scope is active, component or standalone.
  if (getCurrentScope()) {
    onScopeDispose(cancel)
  }

  // Vue 3.5+ alternative to the guard above:
  // onScopeDispose(cancel, true)

  return cancel
}

Testing a composable without mounting a component

A scope gives a composable somewhere to live in a unit test, so computeds and watchers are disposed between cases instead of leaking into the next one. The last line shows the documented behaviour that run() returns undefined once the scope is stopped.

Example · ts
// src/composables/useCounter.spec.ts
import { effectScope, computed, ref } from 'vue'

function useCounter(start = 0) {
  const count = ref(start)
  const doubled = computed(() => count.value * 2)
  return { count, doubled, increment: () => (count.value += 1) }
}

const scope = effectScope()
const counter = scope.run(() => useCounter(2))!

counter.increment()
console.log(counter.count.value, counter.doubled.value)
// Output: 3 6

scope.stop()
// The computed is stopped; nothing is left watching after the test.
console.log(scope.run(() => 'ran') === undefined)
// Output: true

Discussion

  • Be the first to comment on this lesson.