On this page
Composables: reusable stateful logic with the Composition API
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 functionReturning 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 reloadsuseEventListener — 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.tsFor large applications, organize by domain:
src/
composables/
auth/
useAuth.ts
usePermissions.ts
ui/
useModal.ts
useTheme.tsPractice
- useClipboard: Write a composable that exposes a
copy(text: string)function and acopiedref that istruefor 2 seconds after copying. Usenavigator.clipboard.writeText(). - useIntersectionObserver: Write a composable that accepts a template ref and returns an
isVisibleboolean that updates when the element enters or exits the viewport. - 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.
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,
}
}
Sign in to track your progress