Vue composables are almost the best reuse mechanism the framework has ever shipped. Plain functions that can call reactivity primitives, lifecycle hooks, and other composables. No mixins. No provide/inject ceremony. No renderless components doing acrobatics.
And like every powerful primitive, they're easy to misuse. I've seen Vue codebases where every component had its own custom composable, every composable called three more, and a one-line render touched eight files before any actual template showed up. The composables weren't wrong individually — they just weren't earning their keep.
This article is about telling the difference. What composables are good for, what they're bad for, and the small smell test that catches the over-engineered version before it ships.
What Composables Actually Are
A composable is a function. That's the whole secret. The only thing that distinguishes it from a regular function is that it can call reactivity primitives — ref, computed, watch, onMounted, provide, anything Vue exposes. Because of that, the rules of hooks-style usage apply: call them inside setup or another composable, not inside conditional branches that vary per render.
import { ref, onMounted, onUnmounted } from 'vue'
export function useDocumentTitle(title: string) {
const previous = document.title
onMounted(() => { document.title = title })
onUnmounted(() => { document.title = previous })
}
That's a complete composable. Looks like a function because it is a function. The use prefix isn't magic — it's a signal to the linter (and to humans) that the thing inside calls reactive primitives.
When Composables Earn Their Keep
Four shapes consistently pay rent. Outside of these, ask twice before reaching for one.
1. Wrapping a subscription pattern. Anything with subscribe / unsubscribe (event listeners, WebSockets, intervals, observers) becomes much cleaner as a composable:
export function useOnlineStatus() {
const online = ref(navigator.onLine)
const update = () => { online.value = navigator.onLine }
onMounted(() => {
window.addEventListener('online', update)
window.addEventListener('offline', update)
})
onUnmounted(() => {
window.removeEventListener('online', update)
window.removeEventListener('offline', update)
})
return online
}
Setup, teardown, edge cases — written once, reused everywhere. (For production, VueUse ships this exact composable as useOnline. The library is full of well-tested versions of patterns you'd otherwise write yourself.)
2. Encapsulating non-trivial state logic. When state has more than one transition rule, a composable is a good place to keep them together:
export function useToggle(initial = false) {
const value = ref(initial)
const toggle = () => { value.value = !value.value }
const setTrue = () => { value.value = true }
const setFalse = () => { value.value = false }
return { value, toggle, setTrue, setFalse }
}
Tiny, but every consumer gets the same well-named transitions. If you ever need to change one (rename, add logging, persist), you do it in one place.
3. Wrapping a third-party imperative API. Charts, maps, video players, drag-and-drop libraries. The composable becomes the seam: the component renders a <div ref="container" />, the composable owns the imperative dance.
export function useChart(canvasRef: Ref<HTMLCanvasElement | null>, data: Ref<ChartData>) {
let instance: ChartLib | null = null
onMounted(() => {
if (canvasRef.value) instance = new ChartLib(canvasRef.value, data.value)
})
watch(data, (next) => instance?.update(next))
onUnmounted(() => instance?.destroy())
}
The component now just renders the canvas and calls the composable. The setup, update, and cleanup live somewhere a reviewer can find.
4. Sharing genuinely repeated utility logic. useDebounce, useLocalStorage, useMediaQuery, useFocusTrap. Most teams have a shared/composables/ folder for these. Five-line composables beat copy-pasting watch blocks across the codebase.
When Composables Become Hidden Complexity
The patterns that consistently bite, in the order you'll meet them:
1. The composable that fetches.
export function useCart(userId: Ref<number>) {
const cart = ref<CartItem[]>([])
watch(userId, async (id) => {
cart.value = await fetch(`/api/cart?userId=${id}`).then(r => r.json())
}, { immediate: true })
function addItem(item: CartItem) {
fetch('/api/cart/add', { method: 'POST', body: JSON.stringify(item) })
cart.value = [...cart.value, item]
track('cart_add', item)
}
return { cart, addItem }
}
Looks fine. Now imagine two components on the same page both call useCart(). Two GET requests fire. Each addItem triggers two state updates and two analytics events. The composable looks declarative but it's quietly punching holes through your network and analytics layers.
The cure is usually to move the network parts to a server-state library — TanStack Query (@tanstack/vue-query), VueUse's useFetch, or Pinia Colada — and let the composable own only UI concerns. If the composable hits the network, name it accordingly (useCartQuery) so the cost is visible at the call site.
2. The composable chain.
function useUserProfile() {
const user = useUser() // request 1
const settings = useUserSettings(user.value?.id) // request 2 (waits for 1)
const avatar = useAvatar(settings.value?.avatarId) // request 3 (waits for 2)
return { user, settings, avatar }
}
Each is innocent. Together, they're a serial network waterfall hidden inside one render. The component that uses this composable has no way of knowing why it's slow. The fix is one query that fetches everything, or making the dependencies visible at the call site.
3. The composable that returns 14 things. When the return shape outgrows about five values, you usually have two composables pretending to be one. Splitting them makes both easier to read, test, and reuse.
4. The shared-ref nightmare. A composable that uses ref(null) inside its body gives every caller their own ref. If two components both call useGlobalToast(), they don't share the toast — they each get their own:
// ❌ each caller gets their own toast — not actually global
export function useGlobalToast() {
const visible = ref(null) // per-call ref — every caller gets their own
// ...
}
For genuinely shared state, hoist the ref above the function (module-level), or use a Pinia store:
// ✅ shared module-scope ref — every caller sees the same toast
const visible = ref<Toast | null>(null)
export function useGlobalToast() {
return {
visible,
show(message: string) { visible.value = { message, id: Date.now() } },
dismiss() { visible.value = null },
}
}
The Smell Test
When you're deciding whether to extract a composable, ask:
- Does it have a single, namable purpose? "Sync with localStorage" — yes. "Manage the dashboard" — no.
- Is the call site simpler than the original code? If it's the same length plus an import, you saved nothing.
- Does it hide a side effect that should be visible? Network calls, global subscriptions, timers — these often deserve to be on the surface.
- Could it be a regular function? If your composable doesn't call any reactivity primitives, it's just a function. Make it one and remove the
useprefix.
The fourth point is the most common micro-mistake. A composable like:
function useFormatPrice(amount: Ref<number>, currency: Ref<string>) {
return computed(() => new Intl.NumberFormat('en-US', { style: 'currency', currency: currency.value }).format(amount.value))
}
If the inputs are reactive and you return a computed, that's fair. But if you're given plain numbers and currency strings and the function just formats — make it formatPrice(amount, currency), drop the use prefix, and stop importing it as if it were a hook.
A Worked Example: From "OK" To "Good"
Here's a composable from a real codebase that started reasonable and slowly grew teeth:
export function useUserSearch(initialQuery = '') {
const query = ref(initialQuery)
const results = ref<User[]>([])
const loading = ref(false)
const history = ref<string[]>(JSON.parse(localStorage.getItem('search-history') ?? '[]'))
watch(query, async (q) => {
if (!q) return
loading.value = true
try {
results.value = await api.users.search(q)
history.value = [q, ...history.value].slice(0, 10)
} finally {
loading.value = false
}
})
watch(history, (h) => {
localStorage.setItem('search-history', JSON.stringify(h))
}, { deep: true })
return { query, results, loading, history }
}
It works. It also bundles three concerns: the search query (UI state), the network results (server state), and the history persistence (browser state). Three different responsibilities, three different lifecycles, three different bug surfaces.
The split version:
// pure UI state
export function useSearchQuery(initial = '') {
return ref(initial)
}
// server state — uses TanStack Query under the hood
export function useUserSearchResults(query: Ref<string>) {
return useQuery({
queryKey: ['users', 'search', query],
queryFn: () => api.users.search(query.value),
enabled: computed(() => !!query.value),
})
}
// browser state — uses VueUse's useStorage
export function useSearchHistory() {
return useStorage<string[]>('search-history', [])
}
Each piece is testable on its own. Each one uses the right tool. The component composes all three at the call site, which is the place where you want the seams to be visible.
A Mental Model In One Sentence
Composables are great. They reward composition and reuse better than mixins or renderless components ever did. They also let you abstract too early, hide network calls, and produce indirection that doesn't pay rent. Use them when the logic is genuinely reusable across multiple components, the lifecycle is repetitive, or you're wrapping an imperative API. Skip them when you'd be the only consumer or the function doesn't actually call any reactive primitives. The goal is to make the calling component clearer — if the composable doesn't do that, the abstraction isn't earning its keep.




