A composable that leaks a timer, a listener, or a WebSocket connection is a bug that won't show up in a quick manual test — it shows up three hours into a long-lived SPA session as unexplained memory growth and duplicate event firing. This module treats resource cleanup as a first-class part of the composable's contract, not an afterthought.


The Composable Pattern: Functional vs Stateful

A functional composable takes inputs and returns derived reactive values with no independent lifecycle of its own (pure derivation). A stateful composable owns a resource — a timer, subscription, or connection — that must be explicitly acquired and released, tying it to the calling component's lifecycle.

useFormattedCurrency.ts (functional composable)typescript
import { computed, type Ref } from 'vue'

// Purely derives from its input ref — no cleanup needed, no independent lifecycle.
export function useFormattedCurrency(amount: Ref<number>, currency = 'USD') {
  const formatted = computed(() =>
    new Intl.NumberFormat('en-US', { style: 'currency', currency }).format(amount.value)
  )
  return { formatted }
}
useInterval.ts (stateful composable)typescript
import { onUnmounted } from 'vue'

// Owns a real resource (a timer) — MUST clean up, or the interval keeps firing
// against a component instance that no longer exists, silently leaking memory
// and potentially throwing if the callback touches unmounted component state.
export function useInterval(callback: () => void, delayMs: number) {
  const id = setInterval(callback, delayMs)
  onUnmounted(() => clearInterval(id))
}

onUnmounted vs onScopeDispose

onUnmounted only fires inside an active component instance — calling it outside setup()/<script setup> (e.g. from a plain composable invoked outside any component, or from a manually created effectScope()) throws a warning and never fires. onScopeDispose fires whenever the enclosing effect scope is disposed, which includes component unmount but also covers manually created scopes with no component at all.

CriteriaonUnmountedonScopeDispose
Requires an active component instanceYesNo — works inside any effectScope, component or not
Fires on component unmountYesYes (component setup runs inside an implicit effect scope)
Fires on manually disposed effectScope()No — has no meaning outside a componentYes — this is its core use case
Safe to call from a composable used both inside and outside componentsNo — throws/warns if there's no component instanceYes — universally safe
Best use caseComponent-only cleanup where the composable is guaranteed to only ever run inside setup()Library-grade composables that must work whether or not the caller is a component

Rendering & DOM Performance: The Compiler-Informed Fast Path

Vue 3's template compiler statically analyzes the template AST at build time and annotates the generated render function with hints the runtime patch algorithm uses to skip diffing work entirely for parts of the tree that provably cannot change.

OptimizationMechanismEffect on Patch Performance
Patch FlagsEach dynamically-bound vnode is tagged with a bitmask (e.g. TEXT, CLASS, PROPS) indicating exactly which aspect is dynamicThe patch algorithm only diffs the flagged aspect — a vnode flagged TEXT skips prop/class/style comparison entirely
hoistStaticVnodes with zero dynamic bindings are hoisted outside the render function, created once and reused on every renderStatic subtrees are never re-created or re-diffed at all — literally skipped on every subsequent render call
Block Tree / Dynamic Children TrackingA 'block' vnode tracks only its dynamic descendants in a flat array, rather than requiring a full recursive tree walkPatch traversal becomes O(dynamic nodes) instead of O(total nodes), regardless of how deeply nested static wrapper elements are
Cached Event Handlers (cacheHandlers)Inline arrow function event handlers with no external reactive dependencies are cached across renders instead of recreatedAvoids needless prop-equality-breaking re-creation of handler references, which matters when the handler is passed to a memoized child

<Teleport> and Layout Caching

<Teleport> renders a component's content into a different part of the DOM tree while keeping it logically part of the same component instance (props, events, and provide/inject context all still flow normally) — solving the CSS-stacking-context problem that modals, tooltips, and dropdowns hit when nested deep inside a transformed or overflow-clipped ancestor.

ModalDialog.vuevue
<script setup lang="ts">
defineProps<{ open: boolean }>()
defineEmits<{ close: [] }>()
</script>

<template>
  <!-- to="body" escapes any ancestor's overflow:hidden or transform,
       which would otherwise clip or mis-position a fixed-position modal -->
  <Teleport to="body" :disabled="!open">
    <div v-if="open" class="modal-overlay" @click.self="$emit('close')">
      <div class="modal-dialog">
        <slot />
      </div>
    </div>
  </Teleport>
</template>

<style scoped>
.modal-overlay {
  position: fixed;
  inset: 0;
  background: rgba(0, 0, 0, 0.5);
  display: flex;
  align-items: center;
  justify-content: center;
}
.modal-dialog {
  background: #fff;
  border-radius: 8px;
  padding: 1.5rem;
  min-width: 320px;
}
</style>

Practical Example: Memory-Safe useWebSocket Composable

A production WebSocket composable must track connection status reactively, auto-reconnect with backoff on unexpected drops, and — critically — guarantee the socket is closed and every listener removed when the consuming scope is disposed, whether that's a component unmount or a manually stopped effect scope.

useWebSocket.tstypescript
import { ref, onScopeDispose, type Ref } from 'vue'

export type ConnectionStatus = 'connecting' | 'open' | 'closed' | 'reconnecting' | 'error'

interface UseWebSocketOptions {
  maxReconnectAttempts?: number
  baseReconnectDelayMs?: number
  onMessage?: (data: unknown) => void
}

interface UseWebSocketReturn {
  status: Ref<ConnectionStatus>
  lastError: Ref<Error | null>
  send: (payload: unknown) => void
  close: () => void
}

export function useWebSocket(url: string, options: UseWebSocketOptions = {}): UseWebSocketReturn {
  const { maxReconnectAttempts = 5, baseReconnectDelayMs = 1000, onMessage } = options

  const status = ref<ConnectionStatus>('connecting')
  const lastError = ref<Error | null>(null)

  let socket: WebSocket | null = null
  let reconnectAttempts = 0
  let reconnectTimeoutId: ReturnType<typeof setTimeout> | null = null
  let isManuallyClosed = false

  function connect(): void {
    socket = new WebSocket(url)
    status.value = reconnectAttempts > 0 ? 'reconnecting' : 'connecting'

    socket.addEventListener('open', handleOpen)
    socket.addEventListener('message', handleMessage)
    socket.addEventListener('close', handleClose)
    socket.addEventListener('error', handleError)
  }

  function handleOpen(): void {
    status.value = 'open'
    reconnectAttempts = 0
    lastError.value = null
  }

  function handleMessage(event: MessageEvent): void {
    try {
      onMessage?.(JSON.parse(event.data))
    } catch (err) {
      lastError.value = err instanceof Error ? err : new Error('Failed to parse message')
    }
  }

  function handleClose(): void {
    teardownSocket()

    if (isManuallyClosed) {
      status.value = 'closed'
      return
    }

    if (reconnectAttempts >= maxReconnectAttempts) {
      status.value = 'error'
      lastError.value = new Error('Max reconnect attempts exceeded')
      return
    }

    // Exponential backoff: 1s, 2s, 4s, 8s, 16s
    const delay = baseReconnectDelayMs * 2 ** reconnectAttempts
    reconnectAttempts++
    status.value = 'reconnecting'
    reconnectTimeoutId = setTimeout(connect, delay)
  }

  function handleError(): void {
    status.value = 'error'
    lastError.value = new Error('WebSocket connection error')
  }

  function teardownSocket(): void {
    if (!socket) return
    socket.removeEventListener('open', handleOpen)
    socket.removeEventListener('message', handleMessage)
    socket.removeEventListener('close', handleClose)
    socket.removeEventListener('error', handleError)
    socket = null
  }

  function send(payload: unknown): void {
    if (socket?.readyState === WebSocket.OPEN) {
      socket.send(JSON.stringify(payload))
    }
  }

  function close(): void {
    isManuallyClosed = true
    if (reconnectTimeoutId !== null) {
      clearTimeout(reconnectTimeoutId)
      reconnectTimeoutId = null
    }
    socket?.close()
    teardownSocket()
  }

  connect()

  // onScopeDispose (not onUnmounted) so this composable is safe to call from
  // a Pinia store's setup function or a manually created effectScope, not just
  // from inside a component's setup().
  onScopeDispose(() => close())

  return { status, lastError, send, close }
}

Every Cleanup Path This Composable Guarantees

  • Every addEventListener call in connect() has a matching removeEventListener in teardownSocket(), preventing listener accumulation across reconnect cycles
  • isManuallyClosed distinguishes an intentional close() call from an unexpected network drop, so calling close() never triggers a pointless reconnect attempt against a socket the caller deliberately shut down
  • The pending reconnectTimeoutId is explicitly cleared in close() — without this, a scheduled reconnect could fire and open a brand-new socket AFTER the component believes the connection is fully closed
  • onScopeDispose guarantees close() runs even if the composable is used inside a Pinia store or a raw effectScope(), not only inside a component's setup()
LiveOrderFeed.vue (consumer component)vue
<script setup lang="ts">
import { ref } from 'vue'
import { useWebSocket } from './useWebSocket'

interface OrderEvent {
  orderId: string
  status: 'placed' | 'shipped' | 'delivered'
}

const events = ref<OrderEvent[]>([])

const { status, lastError, close } = useWebSocket('wss://api.example.com/orders/stream', {
  maxReconnectAttempts: 5,
  baseReconnectDelayMs: 1000,
  onMessage: (data) => {
    events.value.unshift(data as OrderEvent)
    if (events.value.length > 50) events.value.pop()
  }
})

// No onUnmounted call needed here at all — useWebSocket's own onScopeDispose
// already guarantees cleanup when this component unmounts. Duplicating the
// cleanup call here would be redundant, not incorrect, but the composable
// owning its own lifecycle is the whole point of extracting it.
</script>

<template>
  <div class="order-feed">
    <header class="order-feed__header">
      <h2>Live Order Feed</h2>
      <span class="order-feed__status" :class="`order-feed__status--${status}`">{{ status }}</span>
    </header>

    <p v-if="lastError" class="order-feed__error">{{ lastError.message }}</p>

    <ul class="order-feed__list">
      <li v-for="event in events" :key="event.orderId">
        Order {{ event.orderId }} — {{ event.status }}
      </li>
    </ul>

    <button type="button" @click="close">Disconnect</button>
  </div>
</template>

<style scoped>
.order-feed { font-family: system-ui, sans-serif; max-width: 480px; }
.order-feed__header { display: flex; justify-content: space-between; align-items: center; }
.order-feed__status { font-size: 0.75rem; padding: 0.2rem 0.5rem; border-radius: 999px; background: #f3f4f6; }
.order-feed__status--open { background: #dcfce7; color: #166534; }
.order-feed__status--error { background: #fee2e2; color: #991b1b; }
.order-feed__list { list-style: none; padding: 0; max-height: 300px; overflow-y: auto; }
.order-feed__error { color: #b45309; }
</style>

Module 6 Checkpoint

  1. Q1. Why does useWebSocket use onScopeDispose instead of onUnmounted for its cleanup?

  2. Q2. What is the actual source of Vue 3's rendering performance advantage over a hand-written h() render function?