Chapter 21

Composables Design: State Ownership, Side Effect Cleanup and Common Pitfalls

Vue 3's Composable pattern formalizes something that was nearly impossible to do elegantly in Vue 2: extracting "stateful logic" from components, reusing it across multiple components, while ensuring that the lifecycle of both state and side effects aligns perfectly with the component that uses them. It's not a React Hooks clone โ€” Composables are built on Vue's reactivity system, with no call-order restrictions and no stale closure problems.

Level 1 ยท What You Need to Know (1โ€“3 Years Experience)

Defining a Composable

A Composable is a function that satisfies the following:

  1. Name starts with use (convention, not enforced)
  2. Called inside a component's setup() or another Composable (synchronous context)
  3. Encapsulates stateful logic, typically returning reactive state and related operations
// useCounter.ts โ€” the simplest possible Composable
import { ref } from 'vue'

export function useCounter(initialValue = 0) {
  const count = ref(initialValue)
  
  function increment() { count.value++ }
  function decrement() { count.value-- }
  function reset() { count.value = initialValue }
  
  return { count, increment, decrement, reset }
}
<script setup>
import { useCounter } from './useCounter'

const { count, increment, decrement } = useCounter(10)
</script>

<template>
  <button @click="decrement">-</button>
  <span>{{ count }}</span>
  <button @click="increment">+</button>
</template>

Every call to useCounter() creates its own independent state. ComponentA and ComponentB each call it and own separate count instances.

Side Effects Must Be Cleaned Up

Side effects inside Composables (event listeners, timers, WebSocket connections) must be cleaned up when the component unmounts:

// โŒ Memory leak: listener persists after component unmounts
export function useMouse() {
  const x = ref(0)
  const y = ref(0)
  
  window.addEventListener('mousemove', (e) => {
    x.value = e.clientX
    y.value = e.clientY
  })
  
  return { x, y }
}

// โœ… Correct: cleanup in onUnmounted
import { ref, onUnmounted } from 'vue'

export function useMouse() {
  const x = ref(0)
  const y = ref(0)
  
  const handler = (e: MouseEvent) => {
    x.value = e.clientX
    y.value = e.clientY
  }
  
  window.addEventListener('mousemove', handler)
  
  onUnmounted(() => {
    window.removeEventListener('mousemove', handler)
  })
  
  return { x, y }
}

useEventListener: Production-Grade Implementation

A more robust version supporting any target element with automatic cleanup:

import { onScopeDispose, watch, isRef, toValue } from 'vue'
import type { Ref, MaybeRefOrGetter } from 'vue'

type EventTarget = HTMLElement | Window | Document | null | undefined

export function useEventListener<K extends keyof WindowEventMap>(
  target: MaybeRefOrGetter<EventTarget>,
  event: K,
  listener: (e: WindowEventMap[K]) => void,
  options?: AddEventListenerOptions,
) {
  const add = (el: EventTarget) => {
    el?.addEventListener(event, listener as EventListener, options)
  }
  const remove = (el: EventTarget) => {
    el?.removeEventListener(event, listener as EventListener, options)
  }

  if (isRef(target)) {
    watch(target, (newVal, oldVal) => {
      remove(oldVal)
      add(newVal)
    }, { immediate: true })
  } else {
    add(toValue(target))
  }

  onScopeDispose(() => {
    remove(toValue(target))
  })
}

State Ownership: Component-Level vs Singleton

Component-level (independent per call):

// Each component call creates its own state
export function useLocalStorage<T>(key: string, defaultValue: T) {
  const stored = localStorage.getItem(key)
  const value = ref<T>(stored ? JSON.parse(stored) : defaultValue)
  
  watch(value, (newVal) => {
    localStorage.setItem(key, JSON.stringify(newVal))
  }, { deep: true })
  
  return value
}

Singleton pattern (shared across components via module-level state):

// Module-level ref โ€” all components share the same instance
const globalUser = ref<User | null>(null)

export function useCurrentUser() {
  return {
    user: globalUser,
    setUser: (u: User | null) => { globalUser.value = u }
  }
}

With singletons, side effects cannot rely on onUnmounted because module-level state lives for the entire application lifecycle.


Level 2 ยท How It Actually Works (3โ€“5 Years Experience)

Why Lifecycle Hooks Work Inside Composables

This is the most frequently asked question. onMounted and onUnmounted are global functions โ€” how do they know "which component to register on"?

Answer: the currentInstance (current active component instance) propagation mechanism.

Component setup() begins execution
        โ”‚
        โ–ผ
Vue sets global variable: currentInstance = this component's instance
        โ”‚
        โ–ผ
setup() calls useDataFetch()
        โ”‚
        โ–ผ
useDataFetch() calls onMounted(fn)
        โ”‚
        โ–ผ
onMounted reads currentInstance (still this component!)
and registers fn to currentInstance.lifecycle.mounted
        โ”‚
        โ–ผ
useDataFetch() returns
        โ”‚
        โ–ผ
setup() completes
Vue clears: currentInstance = null

This mechanism requires synchronous calls. If you call a Composable after await in an async setup, currentInstance has already been cleared, and lifecycle hooks cannot register to the correct component.

onUnmounted vs Returning a Cleanup Function

// Pattern 1: onUnmounted auto-cleanup (recommended for components)
export function useWebSocket(url: string) {
  const ws = ref<WebSocket | null>(null)
  const messages = ref<string[]>([])
  
  ws.value = new WebSocket(url)
  ws.value.onmessage = (e) => messages.value.push(e.data)
  
  onUnmounted(() => {
    ws.value?.close()
    ws.value = null
  })
  
  return { ws, messages }
}

// Pattern 2: return cleanup function (for non-component environments)
export function createWebSocketConnection(url: string) {
  const ws = new WebSocket(url)
  const messages: string[] = []
  
  ws.onmessage = (e) => messages.push(e.data)
  
  return {
    messages,
    cleanup: () => ws.close() // caller is responsible for cleanup
  }
}

Pattern 1 is more convenient โ€” automatically aligned with component lifecycle. Pattern 2 is more flexible โ€” usable in tests, Node.js environments, or when lifecycle control is manual.

The currentInstance Propagation Mechanism

Call stack visualization:

ComponentA.setup()
  โ”‚ currentInstance = ComponentA
  โ”œโ”€ useDataFetch()
  โ”‚     โ”‚ currentInstance is still ComponentA
  โ”‚     โ”œโ”€ onMounted(fetchData)
  โ”‚     โ”‚     โ†’ registered to ComponentA.lifecycle.mounted[]
  โ”‚     โ”œโ”€ onUnmounted(cleanup)
  โ”‚     โ”‚     โ†’ registered to ComponentA.lifecycle.unmounted[]
  โ”‚     โ””โ”€ returns { data, error, loading }
  โ”‚
  โ””โ”€ setup() continues...
  currentInstance = null (setup ends)

Production-Grade useFetch Implementation

import { ref, watch, shallowRef, onUnmounted } from 'vue'
import type { Ref, MaybeRefOrGetter } from 'vue'

interface FetchOptions {
  immediate?: boolean
  retries?: number
  retryDelay?: number
}

export function useFetch<T>(
  url: MaybeRefOrGetter<string>,
  options: FetchOptions = {}
) {
  const { immediate = true, retries = 0, retryDelay = 1000 } = options
  
  const data = shallowRef<T | null>(null)
  const error = ref<Error | null>(null)
  const loading = ref(false)
  
  let abortController: AbortController | null = null
  
  async function fetchWithRetry(retriesLeft: number): Promise<T> {
    const controller = new AbortController()
    abortController = controller
    
    try {
      const response = await fetch(toValue(url), { signal: controller.signal })
      if (!response.ok) throw new Error(`HTTP ${response.status}`)
      return await response.json()
    } catch (err) {
      if ((err as Error).name === 'AbortError') throw err
      if (retriesLeft > 0) {
        await new Promise(resolve => setTimeout(resolve, retryDelay))
        return fetchWithRetry(retriesLeft - 1)
      }
      throw err
    }
  }
  
  async function execute() {
    // Race condition handling: cancel previous request
    abortController?.abort()
    
    loading.value = true
    error.value = null
    
    try {
      data.value = await fetchWithRetry(retries)
    } catch (err) {
      if ((err as Error).name !== 'AbortError') {
        error.value = err as Error
      }
    } finally {
      loading.value = false
    }
  }
  
  function abort() {
    abortController?.abort()
    loading.value = false
  }
  
  // Re-fetch when URL changes
  watch(() => toValue(url), () => {
    if (immediate) execute()
  }, { immediate })
  
  onUnmounted(abort)
  
  return { data, error, loading, execute, abort }
}

Three-state management: loading (in-flight), error (failed), data (success) โ€” three mutually exclusive states that cover the complete request lifecycle.

Lifecycle Execution Pattern Diagram

Component-level Composable (recommended):

ComponentA mount
  โ”‚
  โ”œโ”€ useCounter() โ†’ count_A (ref)
  โ”œโ”€ useMouse()  โ†’ {x_A, y_A}
  โ”‚     โ””โ”€ addEventListener โ†’ registers handler_A
  โ”‚
ComponentA unmount
  โ”‚
  โ””โ”€ onUnmounted โ†’ removeEventListener(handler_A)
                   count_A, {x_A, y_A} garbage collected

ComponentB (independent instance):
  โ”œโ”€ useCounter() โ†’ count_B (independent ref)
  โ””โ”€ ...

Singleton Composable:

Module load โ†’ globalCount = ref(0)

ComponentA mount
  โ””โ”€ useGlobalCounter() โ†’ globalCount (shared reference)

ComponentB mount
  โ””โ”€ useGlobalCounter() โ†’ globalCount (same reference!)

ComponentA unmount โ†’ globalCount persists
ComponentB unmount โ†’ globalCount persists (app-level lifecycle)

Level 3 ยท Design Documents and Source Code (Senior Developers)

currentInstance: Set and Cleared in Renderer

// packages/runtime-core/src/component.ts
export let currentInstance: ComponentInternalInstance | null = null

export const setCurrentInstance = (instance: ComponentInternalInstance) => {
  currentInstance = instance
  instance.scope.on() // activate effectScope
}

export const unsetCurrentInstance = () => {
  currentInstance && currentInstance.scope.off()
  currentInstance = null
}
// packages/runtime-core/src/renderer.ts
function setupStatefulComponent(instance, isSSR) {
  setCurrentInstance(instance)    // โ† set global currentInstance
  pauseTracking()
  try {
    const setupResult = callWithErrorHandling(
      setup,
      instance,
      ErrorCodes.SETUP_FUNCTION,
      [instance.props, setupContext],
    )
    // ...
  } finally {
    resetTracking()
    unsetCurrentInstance()         // โ† clear global currentInstance
  }
}

This is why calling Composables after await in async setup fails: when await suspends execution, the finally block runs immediately (clearing currentInstance), but by the time the Promise resolves, another component's setup may have set currentInstance to a different instance.

onScopeDispose vs onUnmounted

onUnmounted registers hooks on the component's effectScope only. onScopeDispose registers on whatever effectScope is currently active:

// packages/reactivity/src/effectScope.ts
export function onScopeDispose(fn: () => void, failSilently?: boolean) {
  if (activeEffectScope) {
    activeEffectScope.cleanups.push(fn)
  } else if (!failSilently) {
    warn(`onScopeDispose() is called when there is no active effect scope...`)
  }
}

For library Composables, onScopeDispose is preferred โ€” it works in Pinia stores, standalone effectScopes, and component contexts alike:

// Library code โ€” onScopeDispose is more universal
export function useTimer(fn: () => void, interval: number) {
  const timer = setInterval(fn, interval)
  // onUnmounted won't work in a Pinia store; onScopeDispose will
  onScopeDispose(() => clearInterval(timer))
}

watch's onCleanup: The Standard Solution for Race Conditions

import { watch, ref } from 'vue'

const id = ref(1)

watch(id, async (newId, oldId, onCleanup) => {
  let cancelled = false
  
  // Register cleanup: called when watch triggers again or stops
  onCleanup(() => {
    cancelled = true
  })
  
  const response = await fetch(`/api/user/${newId}`)
  
  // If id changed while request was in-flight, discard this result
  if (!cancelled) {
    user.value = await response.json()
  }
})

onCleanup is invoked:

  1. Before the next watch trigger (cleanup of old effect)
  2. When watch is stopped via stop()
  3. When the component unmounts

This is the official standard solution for race conditions in async watch callbacks.

VueUse Design Patterns Worth Learning

MaybeRef / MaybeRefOrGetter: Accept ref, getter function, or plain value

import { toValue, type MaybeRefOrGetter } from 'vue'

export function useFoo(value: MaybeRefOrGetter<string>) {
  const normalized = computed(() => toValue(value))
  // toValue(): ref โ†’ .value, getter โ†’ call it, plain value โ†’ return it
}

// Callers can pass anything:
useFoo('hello')                    // plain value
useFoo(ref('hello'))               // ref
useFoo(() => someComputed.value)   // getter

ConfigurableWindow: Support custom window (SSR / iframe / Worker)

interface ConfigurableWindow { window?: Window }

export function useMouse(options: ConfigurableWindow = {}) {
  const { window = defaultWindow } = options
  // Pass undefined in SSR environments
}

Level 4 ยท Edge Cases and Pitfalls (All Levels)

Pitfall 1: Calling Composables Outside setup Context

// โŒ Inside setTimeout โ€” missing lifecycle context
export default {
  setup() {
    setTimeout(() => {
      const { count } = useCounter()
      // Warning: getCurrentInstance() returned null
      // Lifecycle hooks won't register; onUnmounted won't fire
    }, 1000)
  }
}

// โŒ Inside an async callback
async function loadData() {
  await fetch('/api/data')
  const result = useQuery('/api/data') // Error: async callback context
}

// โœ… Only in the synchronous portion of setup()
export default {
  setup() {
    const { count } = useCounter() // correct
    
    onMounted(async () => {
      // async operations inside lifecycle hooks are fine
      // but don't call useCounter() here
      await loadData()
    })
    
    return { count }
  }
}

Diagnosis: Console warning [Vue warn]: getCurrentInstance() returned null. onMounted() was called when there was no active component instance to be associated with.

Pitfall 2: Conditional Composable Calls

// โŒ Conditional call โ€” inconsistent side effect registration
setup() {
  if (isAdmin) {
    const { data } = useAdminData() // registered some renders, not others
  }
}

// โœ… Move the condition inside the Composable
setup() {
  const { data } = useAdminData({ enabled: isAdmin })
}

// Inside useAdminData:
export function useAdminData({ enabled = true } = {}) {
  const data = ref(null)
  
  if (enabled) {
    onMounted(() => fetchAdminData())
  }
  
  return { data }
}

Vue Composables don't have the strict call-order requirements of React Hooks, but keeping conditional logic inside Composables is still best practice for clarity.

Pitfall 3: Singleton State Leaking Between Components

// โŒ Module-level state persists after component unmount
const cache = ref<Map<string, any>>(new Map())

export function useCache() {
  return { cache } // all components share same cache
}

// Testing problem:
// ComponentA writes to cache
// ComponentA unmounts
// ComponentB mounts โ€” cache STILL has ComponentA's data
// โ†’ state leakage, tests pollute each other

Solution: Provide a reset method, or manage state at the app level:

const createCache = () => {
  const cache = ref<Map<string, any>>(new Map())
  return { cache, reset: () => cache.value.clear() }
}

// Create at app initialization โ€” not at module level
const cacheStore = createCache()
app.provide(cacheKey, cacheStore)

Pitfall 4: Destructuring Reactive Objects Loses Reactivity

export function useUser() {
  const user = reactive({ name: 'Alice', role: 'admin' })
  return user // returns reactive object
}

// In a component:
const { name, role } = useUser()
// โŒ name and role are plain strings! Destructuring reactive loses reactivity.
// Mutating user.name won't update name in the template.

Solution 1: Return the reactive object without destructuring

const user = useUser()
// Use user.name, user.role

Solution 2: Return refs instead of reactive (recommended)

export function useUser() {
  const name = ref('Alice')
  const role = ref('admin')
  return { name, role } // refs survive destructuring
}

const { name, role } = useUser() // โœ… both are refs, reactivity preserved

Solution 3: Use toRefs

export function useUser() {
  const user = reactive({ name: 'Alice', role: 'admin' })
  return toRefs(user) // converts each property to a ref
}

const { name, role } = useUser() // โœ… safe to destructure

Pitfall 5: Forgetting to Stop watch Causing Memory Leaks

// โŒ watch created in Composable never stopped
export function useDebounceSearch(query: Ref<string>) {
  const results = ref([])
  
  // This watch keeps running after component unmounts!
  const stop = watch(query, async (q) => {
    results.value = await searchAPI(q)
  })
  
  // โœ… Stop the watcher when component unmounts
  onUnmounted(stop)
  
  return { results }
}

Note: watch called in a valid setup() context (when currentInstance is set) automatically binds to the component's effectScope and stops on unmount. watch created outside setup (module top-level, setTimeout) requires manual cleanup.

Pitfall 6: Lifecycle Registration Order in Nested Composables

export function useA() {
  onMounted(() => console.log('A mounted'))  // registered first
  const { data } = useB() // useB also registers onMounted
  onUnmounted(() => console.log('A unmounted'))
  return { data }
}

export function useB() {
  onMounted(() => console.log('B mounted'))  // registered second
  onUnmounted(() => console.log('B unmounted'))
  return { data: ref(null) }
}

// Execution order:
// mounted: "A mounted" โ†’ "B mounted" (registration order)
// unmounted: "A unmounted" โ†’ "B unmounted" (registration order โ€” NOT reversed)
// Note: unlike React's useEffect cleanup (which runs in reverse order),
// Vue's unmounted hooks run in the same order they were registered

Chapter Summary

  1. Composables are units of stateful logic encapsulation: Named with use, called in synchronous setup context, encapsulating reactive state and side effects โ€” this is Vue 3's core code reuse paradigm, replacing Vue 2's mixins.

  2. The currentInstance propagation mechanism enables cross-function lifecycle hooks: onMounted and onUnmounted work inside Composables because Vue maintains a global currentInstance pointer during setup execution. Any function called synchronously within that window can access it.

  3. Every side effect must have a paired cleanup: Each addEventListener needs a removeEventListener, each setInterval needs a clearInterval. Prefer onScopeDispose over onUnmounted for library code โ€” it works in effectScope environments (like Pinia stores) as well as component contexts.

  4. State ownership determines Composable architecture: Component-level Composables create independent state per call (for local logic); singleton Composables share state across components (for global state, but require app-level lifecycle management).

  5. Destructuring is the leading killer of reactivity: Properties destructured from reactive objects become plain values and lose reactivity. Composables should return ref objects or use toRefs to ensure callers can safely destructure without losing reactive tracking.

Rate this chapter
4.7  / 5  (8 ratings)

๐Ÿ’ฌ Comments