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 (orcomposables/at the Nuxt project root for auto-import). - One composable per file, matching the function name:
useAuth.tsexportsuseAuth.
Input/Output
- Accept refs or plain values as inputs. Use
toValue()(Vue 3.3+) orunref()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
refadds 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, orlocalStoragewill crash during server-side rendering. Guard browser APIs withonMountedor checktypeof window !== 'undefined'.
Key Takeaways
- Composables are functions named
useXxxthat 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.