On this page

Composables: reusable stateful logic with the Composition API

14 min read TextCh. 3 — Composition

What is a composable?

A composable is a function that uses Vue's Composition API to encapsulate and reuse stateful logic. It is the Vue 3 answer to the code-reuse problems that mixins created in Vue 2.

Think of composables as "custom hooks" (in React terminology) — they are plain TypeScript functions that can use ref, reactive, computed, watch, lifecycle hooks, and anything else from the Composition API.

The key advantages over mixins:

  • No naming conflicts — each composable returns its own named values
  • Full type safety — TypeScript infers types naturally
  • Explicit dependencies — you can see exactly what a composable returns
  • Easy to test — just call the function and inspect the returned refs

A minimal composable

The simplest composable wraps a piece of reactive state with operations:

// composables/useToggle.ts
import { ref } from 'vue'

export function useToggle(initialValue = false) {
  const isOn = ref(initialValue)

  function toggle() {
    isOn.value = !isOn.value
  }

  function turnOn()  { isOn.value = true }
  function turnOff() { isOn.value = false }

  return { isOn, toggle, turnOn, turnOff }
}

Usage in any component:

<script setup lang="ts">
import { useToggle } from './composables/useToggle'

const { isOn, toggle } = useToggle(false)
</script>

<template>
  <button type="button" @click="toggle">
    {{ isOn ? 'ON' : 'OFF' }}
  </button>
</template>

Multiple components can call useToggle() and each gets its own independent reactive state — there is no shared state between instances unless you intentionally share a ref.

Composable conventions

Accepting MaybeRefOrGetter inputs

Well-designed composables should accept both reactive and non-reactive inputs. MaybeRefOrGetter<T> and toValue() make this easy:

import { toValue, type MaybeRefOrGetter, computed } from 'vue'

export function useDouble(input: MaybeRefOrGetter<number>) {
  // toValue() unwraps refs, calls getter functions, returns plain values as-is
  return computed(() => toValue(input) * 2)
}

// All three usages work:
const a = useDouble(10)          // plain value
const b = useDouble(myRef)       // Ref<number>
const c = useDouble(() => x * 2) // getter function

Returning refs, not plain values

Always return ref (or reactive) values, not plain values. This preserves reactivity when destructuring:

// WRONG — loses reactivity after destructuring
return {
  count: count.value, // plain number
}

// CORRECT — reactive ref preserved
return {
  count, // Ref<number>
}

Lifecycle hooks inside composables

Composables can use lifecycle hooks. They bind to the component instance that called setup():

import { ref, onMounted, onUnmounted } from 'vue'

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

  function update() {
    width.value  = window.innerWidth
    height.value = window.innerHeight
  }

  onMounted(()   => window.addEventListener('resize', update))
  onUnmounted(() => window.removeEventListener('resize', update))

  return { width, height }
}

useLocalStorage — a practical example

Here is a composable that syncs a ref with localStorage:

import { ref, watch } from 'vue'

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

  const value = ref<T>(initial)

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

  return value
}

Usage:

const theme = useLocalStorage<'light' | 'dark'>('theme', 'light')
// theme is a Ref — changes persist across page reloads

useEventListener — composable with cleanup

import { onMounted, onUnmounted } from 'vue'
import type { MaybeRefOrGetter } from 'vue'
import { toValue } from 'vue'

export function useEventListener<K extends keyof WindowEventMap>(
  target: MaybeRefOrGetter<EventTarget>,
  event: K,
  handler: (event: WindowEventMap[K]) => void
) {
  onMounted(()   => toValue(target).addEventListener(event, handler as EventListener))
  onUnmounted(() => toValue(target).removeEventListener(event, handler as EventListener))
}

// Usage
useEventListener(window, 'keydown', (e) => {
  if (e.key === 'Escape') closeModal()
})

useDebounce — composable that wraps another ref

import { ref, watch } from 'vue'
import type { Ref } from 'vue'

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

  watch(source, (newVal) => {
    clearTimeout(timer)
    timer = setTimeout(() => {
      debounced.value = newVal
    }, delay)
  })

  return debounced
}

Usage:

const searchQuery    = ref('')
const debouncedQuery = useDebounce(searchQuery, 300)

// debouncedQuery only updates 300ms after the user stops typing
watchEffect(() => performSearch(debouncedQuery.value))

Composables vs components vs utilities

Use case Use
Reusable stateful logic (reactive state + effects) Composable
Reusable UI with template Component
Pure transformation functions (no state) Regular utility function

A composable without any reactive state is just a regular function — there is no need for the use prefix.

Organizing composables

Place composables in a src/composables/ directory, one file per composable:

src/
  composables/
    useCounter.ts
    useFetch.ts
    useLocalStorage.ts
    useWindowSize.ts
    useDebounce.ts

For large applications, organize by domain:

src/
  composables/
    auth/
      useAuth.ts
      usePermissions.ts
    ui/
      useModal.ts
      useTheme.ts

Practice

  1. useClipboard: Write a composable that exposes a copy(text: string) function and a copied ref that is true for 2 seconds after copying. Use navigator.clipboard.writeText().
  2. useIntersectionObserver: Write a composable that accepts a template ref and returns an isVisible boolean that updates when the element enters or exits the viewport.
  3. Refactor a component: Take a component with complex logic (fetching, filtering, pagination) and extract each concern into its own composable. Notice how the component becomes a thin orchestration layer.

In the next lesson, we will learn about provide and inject — Vue's dependency injection system for sharing data across deep component trees.

Naming convention
Always prefix composable function names with use (useCounter, useFetch, useLocalStorage). This is the universal community convention — it distinguishes composables from regular utility functions and signals that the function uses Vue's Composition API.
MaybeRefOrGetter
Vue 3.3+ exports the MaybeRefOrGetter<T> type, which represents a value that can be a plain value, a Ref, or a getter function. Combined with toValue(), it lets composables accept reactive or non-reactive inputs without special casing.
Composables must run inside setup()
Composables that use lifecycle hooks (onMounted, onUnmounted) or watchEffect must be called synchronously during component setup. Calling a composable inside a conditional or a callback will not work correctly.
import { ref, computed } from 'vue'

interface UseCounterOptions {
  initial?: number
  min?: number
  max?: number
  step?: number
}

export function useCounter(options: UseCounterOptions = {}) {
  const {
    initial = 0,
    min = -Infinity,
    max = Infinity,
    step = 1,
  } = options

  const count = ref(initial)

  const isAtMin = computed(() => count.value <= min)
  const isAtMax = computed(() => count.value >= max)

  function increment() {
    count.value = Math.min(count.value + step, max)
  }

  function decrement() {
    count.value = Math.max(count.value - step, min)
  }

  function reset() {
    count.value = initial
  }

  function set(value: number) {
    count.value = Math.max(min, Math.min(value, max))
  }

  return {
    count,
    isAtMin,
    isAtMax,
    increment,
    decrement,
    reset,
    set,
  }
}