4 min read
On this page

Composables

Composables are functions that use Vue's Composition API to encapsulate and reuse stateful logic. The pattern is simple: a function named useXxx that creates reactive state, sets up watchers or lifecycle hooks, and returns what the consumer needs. This is how you share logic across components without the disasters that Vue 2 mixins caused.

The composable pattern is not a Vue invention -- React has hooks, Solid has primitives -- but Vue's implementation is arguably the cleanest because composables are just functions. No rules about call order, no restrictions on conditionals, no hooks-specific linting rules.

Anatomy of a Composable

A composable follows a predictable structure:

// composables/useDebounce.ts
import { ref, watch } from 'vue'
import type { Ref } from 'vue'

export function useDebounce<T>(source: Ref<T>, delay: number = 300): Ref<T> {
  const debounced = ref(source.value) as Ref<T>
  let timeout: ReturnType<typeof setTimeout>

  watch(source, (newValue) => {
    clearTimeout(timeout)
    timeout = setTimeout(() => {
      debounced.value = newValue
    }, delay)
  })

  return debounced
}

Usage in a component:

<script setup lang="ts">
import { ref, watch } from 'vue'
import { useDebounce } from '@/composables/useDebounce'

const searchInput = ref('')
const debouncedSearch = useDebounce(searchInput, 400)

// Only fires 400ms after the user stops typing
watch(debouncedSearch, async (query) => {
  if (query.length >= 2) {
    const res = await fetch(`/api/search?q=${query}`)
    results.value = await res.json()
  }
})
</script>

<template>
  <input v-model="searchInput" placeholder="Search..." />
</template>

The component does not know or care about setTimeout or clearTimeout. It gets a debounced ref and uses it like any other reactive value.

Real-World Composables

useFetch

A fetch composable that handles loading, errors, and abort:

// composables/useFetch.ts
import { ref, watchEffect, type Ref } from 'vue'

interface UseFetchReturn<T> {
  data: Ref<T | null>
  error: Ref<string | null>
  loading: Ref<boolean>
  refresh: () => void
}

export function useFetch<T>(url: Ref<string> | string): UseFetchReturn<T> {
  const data = ref<T | null>(null) as Ref<T | null>
  const error = ref<string | null>(null)
  const loading = ref(false)

  function doFetch(fetchUrl: string, controller: AbortController) {
    loading.value = true
    error.value = null

    fetch(fetchUrl, { signal: controller.signal })
      .then(res => {
        if (!res.ok) throw new Error(`HTTP ${res.status}`)
        return res.json()
      })
      .then(json => {
        data.value = json
      })
      .catch(err => {
        if (err.name !== 'AbortError') {
          error.value = err.message
        }
      })
      .finally(() => {
        loading.value = false
      })
  }

  let controller = new AbortController()

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

    const resolvedUrl = typeof url === 'string' ? url : url.value
    doFetch(resolvedUrl, controller)
  })

  function refresh() {
    controller.abort()
    controller = new AbortController()
    const resolvedUrl = typeof url === 'string' ? url : url.value
    doFetch(resolvedUrl, controller)
  }

  return { data, error, loading, refresh }
}
<script setup lang="ts">
import { computed } from 'vue'
import { useFetch } from '@/composables/useFetch'

const props = defineProps<{ userId: number }>()

const url = computed(() => `/api/users/${props.userId}`)
const { data: user, error, loading, refresh } = useFetch<{ name: string; email: string }>(url)
</script>

<template>
  <div v-if="loading">Loading...</div>
  <div v-else-if="error">Error: {{ error }}</div>
  <div v-else-if="user">
    <h2>{{ user.name }}</h2>
    <p>{{ user.email }}</p>
    <button @click="refresh">Reload</button>
  </div>
</template>

Note that this is a teaching example. In production Nuxt apps, you would use the built-in useFetch or useAsyncData which handle SSR hydration, de-duplication, and caching. But the pattern is identical, and understanding it helps you read Nuxt's source code.

useLocalStorage

Persist a ref to localStorage with automatic sync:

// composables/useLocalStorage.ts
import { ref, watch } from 'vue'
import type { Ref } from 'vue'

export function useLocalStorage<T>(key: string, defaultValue: T): Ref<T> {
  const stored = localStorage.getItem(key)
  let initial: T = defaultValue

  if (stored !== null) {
    try {
      initial = JSON.parse(stored)
    } catch {
      initial = defaultValue
    }
  }

  const data = ref<T>(initial) as Ref<T>

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

  // Listen for changes from other tabs
  window.addEventListener('storage', (event) => {
    if (event.key === key && event.newValue !== null) {
      try {
        data.value = JSON.parse(event.newValue)
      } catch {
        // Ignore malformed data from other tabs
      }
    }
  })

  return data
}
<script setup lang="ts">
import { useLocalStorage } from '@/composables/useLocalStorage'

const theme = useLocalStorage<'light' | 'dark'>('theme', 'light')
const sidebarOpen = useLocalStorage('sidebar-open', true)

function toggleTheme() {
  theme.value = theme.value === 'light' ? 'dark' : 'light'
}
</script>

useEventListener

A composable that auto-cleans up event listeners:

// composables/useEventListener.ts
import { onMounted, onUnmounted, type Ref, unref } from 'vue'

export function useEventListener<K extends keyof WindowEventMap>(
  target: Window | Ref<HTMLElement | null>,
  event: K,
  handler: (e: WindowEventMap[K]) => void
) {
  onMounted(() => {
    const el = unref(target)
    if (el) el.addEventListener(event, handler as EventListener)
  })

  onUnmounted(() => {
    const el = unref(target)
    if (el) el.removeEventListener(event, handler as EventListener)
  })
}

This is a building block. You use it inside other composables:

// composables/useWindowSize.ts
import { ref } from 'vue'
import { useEventListener } from './useEventListener'

export function useWindowSize() {
  const width = ref(window.innerWidth)
  const height = ref(window.innerHeight)

  useEventListener(window, 'resize', () => {
    width.value = window.innerWidth
    height.value = window.innerHeight
  })

  return { width, height }
}

Composable Conventions

The Vue community follows consistent conventions that you should stick to:

Naming

  • Always prefix with use: useAuth, useCart, usePermissions.
  • Place in a composables/ directory (or composables/ at the Nuxt project root for auto-import).
  • One composable per file, matching the function name: useAuth.ts exports useAuth.

Input/Output

  • Accept refs or plain values as inputs. Use toValue() (Vue 3.3+) or unref() to normalize:
import { toValue, type MaybeRefOrGetter } from 'vue'

export function useTitle(title: MaybeRefOrGetter<string>) {
  watchEffect(() => {
    document.title = toValue(title)
  })
}

// All three work:
useTitle('Static Title')
useTitle(ref('Reactive Title'))
useTitle(() => `Page ${page.value}`)
  • Return an object of refs, not a reactive object. This lets consumers destructure without losing reactivity:
// Good: consumers can destructure
return { data, error, loading }

// Bad: destructuring loses reactivity
return reactive({ data, error, loading })

Lifecycle Awareness

Composables that use onMounted, onUnmounted, or other lifecycle hooks must be called during setup. You cannot call them inside a setTimeout or after an await:

// This works
export function useScrollPosition() {
  const y = ref(0)
  onMounted(() => {
    window.addEventListener('scroll', () => { y.value = window.scrollY })
  })
  return { y }
}

// This BREAKS if called after await
async function loadData() {
  await fetch('/api/data')
  useScrollPosition() // onMounted will not fire
}

This is the one place where Vue composables have a restriction similar to React hooks. Lifecycle hooks must be registered synchronously during setup.

Composing Composables

The real power shows up when composables build on each other. Here is a useInfiniteScroll that combines several primitives:

// composables/useInfiniteScroll.ts
import { ref, watch, type Ref } from 'vue'
import { useEventListener } from './useEventListener'

interface UseInfiniteScrollOptions {
  threshold?: number
  container?: Ref<HTMLElement | null>
}

export function useInfiniteScroll(
  loadMore: () => Promise<void>,
  options: UseInfiniteScrollOptions = {}
) {
  const { threshold = 200, container } = options
  const loading = ref(false)

  async function check() {
    if (loading.value) return

    const el = container?.value || document.documentElement
    const scrollBottom = el.scrollHeight - el.scrollTop - el.clientHeight

    if (scrollBottom < threshold) {
      loading.value = true
      await loadMore()
      loading.value = false
    }
  }

  useEventListener(window, 'scroll', check)

  return { loading }
}

Each composable does one thing. useEventListener manages the listener lifecycle. useInfiniteScroll manages the scroll detection logic. The component just passes a loadMore function and gets a loading state back.

VueUse: Do Not Reinvent the Wheel

Before writing a composable, check VueUse (vueuse.org). It is a collection of 200+ composables maintained by Anthony Fu and the Vue core team. It covers:

  • Browser APIs: useClipboard, useMediaQuery, useFullscreen, useGeolocation
  • Sensors: useMouse, useScroll, useIntersectionObserver
  • State: useStorage, useRefHistory, useCycleList
  • Network: useWebSocket, useEventSource, useFetch
  • Utilities: useDebounce, useThrottle, useToggle

VueUse composables are tree-shakeable, well-typed, and battle-tested. Writing your own useLocalStorage is a good learning exercise, but in production, install @vueuse/core and move on.

Common Pitfalls

  • Not cleaning up side effects: If your composable adds event listeners, timers, or subscriptions, clean them up in onUnmounted. Leaked listeners cause memory leaks and ghost updates.
  • Returning reactive instead of refs: return reactive({ data, error }) looks clean but breaks destructuring. Always return plain objects of refs.
  • Overly granular composables: A composable that wraps a single ref adds indirection without value. Composables should encapsulate meaningful logic involving multiple reactive values, watchers, or lifecycle hooks.
  • Calling composables conditionally: Unlike React hooks, Vue composables technically can be called conditionally because Vue does not rely on call order. But composables using lifecycle hooks (onMounted, onUnmounted) must be called synchronously during setup, so conditional calling is still risky.
  • Ignoring SSR: Composables that access window, document, or localStorage will crash during server-side rendering. Guard browser APIs with onMounted or check typeof window !== 'undefined'.

Key Takeaways

  • Composables are functions named useXxx that encapsulate reactive logic. They are Vue's primary code reuse mechanism.
  • Accept flexible inputs (MaybeRefOrGetter) and return objects of refs for clean consumer ergonomics.
  • Composables compose naturally: build complex behavior by combining simpler composables.
  • Check VueUse before writing common composables from scratch. It covers most standard use cases.
  • Always clean up side effects. Always guard browser APIs for SSR compatibility.
  • Composables replaced mixins entirely. There is no reason to use mixins in Vue 3.