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:
- Name starts with
use(convention, not enforced) - Called inside a component's
setup()or another Composable (synchronous context) - 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:
- Before the next watch trigger (cleanup of old effect)
- When
watchis stopped viastop() - 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
-
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. -
The
currentInstancepropagation mechanism enables cross-function lifecycle hooks:onMountedandonUnmountedwork inside Composables because Vue maintains a globalcurrentInstancepointer during setup execution. Any function called synchronously within that window can access it. -
Every side effect must have a paired cleanup: Each
addEventListenerneeds aremoveEventListener, eachsetIntervalneeds aclearInterval. PreferonScopeDisposeoveronUnmountedfor library code — it works in effectScope environments (like Pinia stores) as well as component contexts. -
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).
-
Destructuring is the leading killer of reactivity: Properties destructured from
reactiveobjects become plain values and lose reactivity. Composables should returnrefobjects or usetoRefsto ensure callers can safely destructure without losing reactive tracking.