Skip to main content
Engineering7 min readMarch 3, 2026

Vue 3 Composables: The Reusability Pattern That Changes Everything

A deep-dive into Vue 3 composables — how to write them well, when to use them vs components or Pinia, real patterns from production apps, and the mistakes to avoid.

James Ross Jr.

James Ross Jr.

Strategic Systems Architect & Enterprise Software Developer

Composables are the single most important pattern in Vue 3, and most developers are not using them to their full potential. I see codebases with composables that are little more than named collections of refs — none of the cross-component reuse, none of the lifecycle encapsulation, none of the abstraction power that makes the pattern valuable.

This guide is about composables done correctly: when they genuinely help, what makes them well-designed, and the patterns from real production applications.

What Makes a Good Composable

A composable is a function that uses Vue's Composition API to encapsulate stateful logic. The distinguishing feature is that it can use reactive state, computed properties, lifecycle hooks, and watchers — and all of those things get properly cleaned up when the component using the composable is unmounted.

A good composable does one thing well. It has a clear name that starts with use and describes the logical concern. It returns only what callers need — not everything it uses internally. It handles its own cleanup.

A bad composable is a bag of loosely related refs and functions that happened to be grouped together. That is not reusability, that is just organization.

The Anatomy of a Well-Designed Composable

// composables/useWebSocket.ts
interface WebSocketOptions {
  url: string
  onMessage?: (data: unknown) => void
  reconnectInterval?: number
}

export function useWebSocket(options: WebSocketOptions) {
  const { url, onMessage, reconnectInterval = 3000 } = options

  const status = ref<'connecting' | 'connected' | 'disconnected' | 'error'>('disconnected')
  const lastMessage = ref<unknown>(null)
  let socket: WebSocket | null = null
  let reconnectTimer: ReturnType<typeof setTimeout> | null = null

  function connect() {
    status.value = 'connecting'
    socket = new WebSocket(url)

    socket.onopen = () => {
      status.value = 'connected'
    }

    socket.onmessage = (event) => {
      const data = JSON.parse(event.data)
      lastMessage.value = data
      onMessage?.(data)
    }

    socket.onclose = () => {
      status.value = 'disconnected'
      if (reconnectInterval > 0) {
        reconnectTimer = setTimeout(connect, reconnectInterval)
      }
    }

    socket.onerror = () => {
      status.value = 'error'
    }
  }

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

  function disconnect() {
    if (reconnectTimer) clearTimeout(reconnectTimer)
    reconnectInterval = 0  // prevent reconnection
    socket?.close()
  }

  // Automatically connect when used in a component
  onMounted(connect)

  // Automatically disconnect when component unmounts
  onUnmounted(disconnect)

  return {
    status: readonly(status),
    lastMessage: readonly(lastMessage),
    send,
    disconnect,
    connect,
  }
}

This composable:

  • Has a single, clear responsibility (WebSocket connection management)
  • Handles its own lifecycle (connects on mount, disconnects on unmount)
  • Exposes a minimal surface area (only what callers need)
  • Returns reactive state as readonly to prevent callers from bypassing the composable's logic
  • Is fully self-contained — the WebSocket logic does not leak into the component

Composable Patterns From Production

Async Data With Abort

Any composable that fetches data should support aborting in-flight requests when the component unmounts or the input changes:

export function useUser(userId: MaybeRefOrGetter<string>) {
  const user = ref<User | null>(null)
  const loading = ref(false)
  const error = ref<string | null>(null)

  watchEffect(async (onCleanup) => {
    const controller = new AbortController()
    onCleanup(() => controller.abort())

    loading.value = true
    error.value = null

    try {
      const id = toValue(userId)
      user.value = await $fetch(`/api/users/${id}`, {
        signal: controller.signal,
      })
    } catch (e) {
      if (e instanceof Error && e.name !== 'AbortError') {
        error.value = e.message
      }
    } finally {
      loading.value = false
    }
  })

  return { user: readonly(user), loading: readonly(loading), error: readonly(error) }
}

The onCleanup callback inside watchEffect runs when the effect re-runs (because userId changed) or when the component unmounts. Aborting previous requests prevents race conditions where a fast second request resolves before a slow first request, leaving the old data on screen.

Local Storage Sync

export function useLocalStorage<T>(key: string, defaultValue: T) {
  const storedValue = localStorage.getItem(key)
  const parsed = storedValue ? JSON.parse(storedValue) : defaultValue

  const value = ref<T>(parsed)

  watch(value, (newValue) => {
    localStorage.setItem(key, JSON.stringify(newValue))
  }, { deep: true })

  return value
}

Usage:

const theme = useLocalStorage<'light' | 'dark'>('theme', 'light')
// Changing theme.value automatically persists to localStorage

Intersection Observer (Lazy Loading)

export function useIntersectionObserver(
  target: MaybeRefOrGetter<Element | null>,
  callback: IntersectionObserverCallback,
  options: IntersectionObserverInit = {}
) {
  const isIntersecting = ref(false)
  let observer: IntersectionObserver | null = null

  const stopObserving = watchEffect(() => {
    const el = toValue(target)
    if (!el) return

    observer = new IntersectionObserver((entries) => {
      isIntersecting.value = entries[0]?.isIntersecting ?? false
      callback(entries, observer!)
    }, options)

    observer.observe(el)
  })

  onUnmounted(() => {
    observer?.disconnect()
    stopObserving()
  })

  return { isIntersecting: readonly(isIntersecting) }
}

Debounced Value

export function useDebouncedRef<T>(value: MaybeRefOrGetter<T>, delay = 300) {
  const debouncedValue = ref<T>(toValue(value))
  let timeout: ReturnType<typeof setTimeout>

  watch(
    () => toValue(value),
    (newValue) => {
      clearTimeout(timeout)
      timeout = setTimeout(() => {
        debouncedValue.value = newValue
      }, delay)
    }
  )

  onUnmounted(() => clearTimeout(timeout))

  return readonly(debouncedValue)
}

Usage in a search component:

<script setup lang="ts">
const searchInput = ref('')
const debouncedSearch = useDebouncedRef(searchInput, 400)

// Only fires the API call after typing stops for 400ms
const { data: results } = useAsyncData(
  () => `search-${debouncedSearch.value}`,
  () => $fetch(`/api/search?q=${debouncedSearch.value}`),
  { watch: [debouncedSearch] }
)
</script>

When Not to Use a Composable

Not every piece of logic needs to be a composable. The overhead of creating a composable — naming it, designing its API, writing its documentation — is only worth it when the logic is genuinely reused or when the encapsulation is meaningful.

Keep logic inline in the component when:

  • It is only used in one place and unlikely to be reused
  • It is simple enough that a composable adds more indirection than clarity
  • It is tightly coupled to that specific component's template logic

Move to a composable when:

  • The same logic appears in two or more components
  • The logic involves lifecycle hooks that need cleanup
  • The logic is complex enough that the component gets hard to read
  • The logic is independently testable and tested

Testing Composables

Composables are the most testable part of a Vue application. They are just functions — no DOM needed for most of them:

import { describe, it, expect } from 'vitest'

describe('useDebouncedRef', () => {
  it('debounces value changes', async () => {
    const source = ref('initial')
    const debounced = useDebouncedRef(source, 100)

    expect(debounced.value).toBe('initial')

    source.value = 'changed'
    expect(debounced.value).toBe('initial') // Not yet updated

    await new Promise(resolve => setTimeout(resolve, 150))
    expect(debounced.value).toBe('changed') // Now updated
  })
})

The Composition API's design makes composables naturally testable. The reactive state is explicit, the dependencies are clear, and the lifecycle hooks can be triggered programmatically in tests.

Naming and Documentation

A composable's name is its primary documentation. useFetch tells you more than useData. useIntersectionObserver tells you more than useVisible. useLocalStorage tells you more than useStorage.

For complex composables, add a JSDoc comment with an example:

/**
 * Syncs a reactive value with localStorage.
 *
 * @param key - The localStorage key to use
 * @param defaultValue - The value to use when the key is not set
 * @returns A writable ref that automatically persists to localStorage
 *
 * @example
 * const theme = useLocalStorage('theme', 'light')
 * theme.value = 'dark' // Automatically persisted
 */
export function useLocalStorage<T>(key: string, defaultValue: T) {

Composables are the building blocks of a well-structured Vue 3 application. Design them with the same care you would give any public API — they will be called from many places over the lifetime of your project.


Working on a Vue 3 or Nuxt codebase and want a review of your composable patterns or state management approach? I am happy to help — book a call at calendly.com/jamesrossjr.


Keep Reading