Vue 3 Composables: The useX Pattern, Reactivity and TypeScript
Vue 3 composables beat mixins at every level - here's how to build typed, testable useX functions with the Composition API and avoid the gotchas that bite teams in production.
Why Composables Replaced Mixins (and Why That's a Good Thing)
If you spent any time with Vue 2 mixins, you know the drill. Property naming collisions, zero visibility into where a method comes from, and a this-context maze that makes TypeScript nearly useless. Vue 3 shipped the Composition API in late 2020 and composables - plain JavaScript functions following the useX naming convention - became the idiomatic answer to all of it.
Honestly, the shift is bigger than just syntax. Composables are regular functions. They're independently importable, independently testable, and they compose without fighting each other. When you call useMouse() inside a component, you know exactly where every returned value comes from - there's no implicit merging happening behind the scenes.
That said, composables aren't magic. They still live inside Vue's reactivity system, which means they need to be called inside setup() or <script setup> - not inside lifecycle hooks, not inside event handlers, not conditionally. Break that rule and you'll get silent bugs that are deeply unpleasant to track down.
Worth noting: the useX prefix is a convention, not a language-level constraint. Vue doesn't enforce it. But you should - it immediately signals to every reader that a function participates in the reactivity system and may call lifecycle hooks internally.
The Anatomy of a Composable
A composable is just a function that uses Vue's reactivity primitives (ref, reactive, computed, watch) and optionally registers lifecycle hooks (onMounted, onUnmounted). It returns whatever the caller needs - usually a mix of reactive state and methods.
Here's the canonical example everyone starts with, but typed properly this time:
``ts
// composables/useMouse.ts
import { ref, onMounted, onUnmounted } from 'vue'
interface MousePosition {
x: Readonly<ReturnType<typeof ref<number>>>
y: Readonly<ReturnType<typeof ref<number>>>
}
export function useMouse(): MousePosition {
const x = ref<number>(0)
const y = ref<number>(0)
function update(event: MouseEvent) {
x.value = event.pageX
y.value = event.pageY
}
onMounted(() => window.addEventListener('mousemove', update))
onUnmounted(() => window.removeEventListener('mousemove', update))
return { x, y }
}
`
Call it inside any component:
`vue
<script setup lang="ts">
import { useMouse } from '@/composables/useMouse'
const { x, y } = useMouse()
</script>
<template>
<p>Mouse: {{ x }}, {{ y }}</p>
</template>
``
Notice the onMounted and onUnmounted hooks live inside the composable. This is one of the most important properties of the pattern - lifecycle management travels with the logic that needs it. You don't have to remember to clean up event listeners in the component; the composable handles it.
In practice, I'd lean toward returning refs rather than a reactive object from a composable. Reactive objects lose reactivity when you destructure them - const { x } = useReactiveObject() gives you a plain number, not a reactive ref. Returning individual refs keeps destructuring safe and keeps the template syntax consistent ({{ x }} auto-unwraps).
Reactivity: ref vs reactive vs computed - Pick the Right Tool
ref wraps any single value in a reactive container. Inside <script setup> or the template, refs auto-unwrap so you write {{ count }} not {{ count.value }}. In plain TypeScript code you always need .value. This is the single biggest source of Vue 3 confusion, so get it burned in early.
reactive wraps an object and makes every property reactive without .value. Sounds cleaner until you need to pass a property to a child component or a composable - then you're back to toRef() or toRefs() to preserve reactivity across the boundary. For most composable-internal state, ref is the safer default.
``ts
import { ref, reactive, computed, toRefs } from 'vue'
// DON'T do this - caller destructures into dead values
export function useBroken() {
const state = reactive({ count: 0, label: 'clicks' })
return state // { count, label } lose reactivity on destructure
}
// DO this - caller gets live refs
export function useCounter(initial = 0) {
const count = ref<number>(initial)
const doubled = computed(() => count.value * 2)
function increment() { count.value++ }
function reset() { count.value = initial }
return { count, doubled, increment, reset }
}
``
computed is your go-to for derived state. It memoizes automatically - Vue only recalculates the getter when a reactive dependency changes. Don't reach for watch to compute derived values; that's the pattern that leads to messy double-state syncing.
Quick aside: watchEffect versus watch. watchEffect runs immediately and tracks every reactive thing it touches - great for side effects with multiple dependencies. watch is explicit, lazy by default, and gives you the old and new values. Use watchEffect for broad side effects and watch when you specifically need old/new comparison or want to control when tracking starts.
TypeScript Integration: Typing Composables Properly
Vue 3.3 (released mid-2023) cleaned up generic component and composable types significantly. If you're on anything older than 3.3, upgrade - the DX gap is real. The defineProps generic syntax and better ref inference alone are worth it.
The most useful pattern for complex composables is to define an explicit options interface and a return type interface:
``ts
// composables/useFetch.ts
import { ref, readonly } from 'vue'
import type { Ref } from 'vue'
interface FetchOptions {
immediate?: boolean
headers?: Record<string, string>
}
interface UseFetchReturn<T> {
data: Readonly<Ref<T | null>>
error: Readonly<Ref<Error | null>>
loading: Readonly<Ref<boolean>>
execute: () => Promise<void>
}
export function useFetch<T>(
url: string | Ref<string>,
options: FetchOptions = {}
): UseFetchReturn<T> {
const data = ref<T | null>(null)
const error = ref<Error | null>(null)
const loading = ref(false)
async function execute() {
loading.value = true
error.value = null
try {
const resolvedUrl = typeof url === 'string' ? url : url.value
const res = await fetch(resolvedUrl, { headers: options.headers })
if (!res.ok) throw new Error(HTTP ${res.status})
data.value = await res.json() as T
} catch (e) {
error.value = e instanceof Error ? e : new Error(String(e))
} finally {
loading.value = false
}
}
if (options.immediate !== false) execute()
return {
data: readonly(data),
error: readonly(error),
loading: readonly(loading),
execute,
}
}
``
Note the readonly() wrapping on returned refs. Callers get reactive reads but can't accidentally mutate internal state directly. It's a small discipline that pays off in every team codebase - you'll thank yourself the first time a junior dev tries to do data.value = null from a component and gets a TypeScript error instead of a silent bug.
When your composable accepts either a plain value or a ref as an argument - like the url: string | Ref<string> above - Vue ships a MaybeRef<T> type helper (and MaybeRefOrGetter<T> in 3.3+) exactly for this. Combine it with toValue() (also 3.3+, replaces unref() for getters) and your composables become genuinely flexible without extra boilerplate.
``ts
import { toValue, type MaybeRefOrGetter } from 'vue'
export function useTitle(title: MaybeRefOrGetter<string>) {
watchEffect(() => {
document.title = toValue(title)
})
}
// Works with all three call styles:
useTitle('Static string')
useTitle(ref('Reactive ref'))
useTitle(() => ${user.name}'s dashboard) // getter
``
Composable Patterns Worth Stealing
Beyond the basics, a few patterns come up constantly in real Vue 3 codebases. The first is stateful singletons - sometimes you want a composable to share a single reactive state across all callers, not create fresh state per call. Move the reactive state outside the function:
``ts
// Shared state across all component instances
const globalCount = ref(0)
export function useGlobalCounter() {
return {
count: readonly(globalCount),
increment: () => globalCount.value++,
}
}
``
This is a lightweight alternative to Pinia for simple cross-component state. For anything non-trivial, use Pinia - but don't reach for a full store when a shared ref does the job in 5 lines.
The second pattern is composable composition - composables calling other composables. This is where the pattern really shines versus mixins. You can build useAuthenticatedFetch on top of useFetch and useAuth without any of them knowing about each other:
``ts
export function useAuthenticatedFetch<T>(url: string | Ref<string>) {
const { token } = useAuth() // another composable
return useFetch<T>(url, {
headers: computed(() =>
token.value ? { Authorization: Bearer ${token.value} } : {}
),
})
}
``
Look, the third pattern is the one teams skip and regret: cleanup via `onScopeDispose`. If you're creating subscriptions, intervals, or WebSocket connections inside a composable, don't just use onUnmounted. Use onScopeDispose instead - it works both in component lifecycle context and in detached effect scopes (like Pinia stores or effectScope()). It's a 5-minute change that makes your composables genuinely portable.
``ts
import { onScopeDispose } from 'vue'
export function useInterval(callback: () => void, ms: number) {
const id = setInterval(callback, ms)
onScopeDispose(() => clearInterval(id))
}
``
One more thing - testing. Composables are just functions, so you can test them without mounting a component. Vue Test Utils ships withSetup as a pattern, but for pure composables you can also use effectScope directly from @vue/reactivity:
``ts
import { effectScope } from 'vue'
import { useCounter } from '@/composables/useCounter'
test('increment updates count', () => {
const scope = effectScope()
const { count, increment } = scope.run(() => useCounter(10))!
expect(count.value).toBe(10)
increment()
expect(count.value).toBe(11)
scope.stop() // trigger cleanup hooks
})
``
Real-World Composable: useLocalStorage with Type Safety
Here's something you'd actually ship - a useLocalStorage composable that's fully typed, syncs across tabs, and handles JSON parse errors without crashing:
``ts
// composables/useLocalStorage.ts
import { ref, watch } from 'vue'
import type { Ref } from 'vue'
export function useLocalStorage<T>(
key: string,
defaultValue: T
): Ref<T> {
// Read initial value from storage
const stored = localStorage.getItem(key)
let initial: T = defaultValue
if (stored !== null) {
try {
initial = JSON.parse(stored) as T
} catch {
console.warn([useLocalStorage] Failed to parse key "${key}", using default)
}
}
const value = ref<T>(initial) as Ref<T>
// Persist changes to storage
watch(
value,
(newVal) => localStorage.setItem(key, JSON.stringify(newVal)),
{ deep: true }
)
// Sync across tabs (StorageEvent fires in OTHER tabs)
function onStorage(event: StorageEvent) {
if (event.key !== key || event.newValue === null) return
try {
value.value = JSON.parse(event.newValue) as T
} catch { /* ignore */ }
}
onMounted(() => window.addEventListener('storage', onStorage))
onUnmounted(() => window.removeEventListener('storage', onStorage))
return value
}
`
Usage is just:
`ts
const theme = useLocalStorage<'light' | 'dark'>('theme', 'light')
const cart = useLocalStorage<CartItem[]>('cart', [])
``
The deep: true watcher on line 22 is load-bearing for array and object types - without it, mutations to nested properties won't trigger a storage write. For primitive values you can drop it and save a few bytes of overhead.
This is the kind of composable you write once, drop into src/composables/, and never think about again. That's the pitch for the whole pattern - extractable, reusable logic that lives outside your component tree and makes components leaner. Building a design system or component library in Vue? This same philosophy applies to every animation hook, every form validation helper, and every data-fetching layer. The Empire UI component library is built on this kind of encapsulation principle - grab a component, the logic is already composable under the hood.
Common Pitfalls and How to Dodge Them
Calling a composable outside setup() is the most common mistake by a wide margin. If you call useSomething() inside an async function, a method, or a setTimeout, Vue can't associate the lifecycle hooks with the current component instance. You get a warning in dev mode and silent failures in production. Always call composables at the top level of setup() - treat them like useState in React if that mental model helps.
The second pitfall is reactive state that should be per-instance but accidentally becomes shared. If you initialize ref(0) inside the function body, every call to the composable gets its own fresh ref. If you put that ref(0) outside the function body (module scope), every caller shares it. Both are valid patterns - just be deliberate about which one you want.
Watch out for watchers that don't clean themselves up. A watch() or watchEffect() call inside a composable automatically stops when the component unmounts, but only if the composable was called synchronously during setup. If you create a watcher dynamically (after an await, say), you need to manually call the stop handle:
``ts
const stopWatch = watch(someRef, handler)
onUnmounted(stopWatch)
``
Finally, don't async the composable function itself. If you need async data, call an async function *inside* the composable but keep the composable synchronous. Return a loading ref and a data ref, let the internal async work resolve reactively. Async setup() does exist in Vue 3, but async composables break predictable hook registration and are hard to reason about. Keep composables sync, let reactivity handle the async story.
FAQ
Yes, but with limits. Lifecycle hooks like onMounted won't fire outside a component context, but pure reactive state and watch/computed work fine. Use effectScope() from @vue/reactivity if you need a managed reactive context outside components - Pinia does this internally.
A composable creates fresh state per call by default; a Pinia store is a singleton shared across your whole app. Use composables for per-component or per-instance logic, Pinia for genuinely global state like auth, cart, or theme. You can also make a composable a singleton by lifting state to module scope.
reactive() uses a JavaScript Proxy - when you destructure, you extract the raw value at that moment, losing the Proxy wrapper. Use toRefs(state) to convert each property to a ref before destructuring, or just return individual refs from your composables to avoid the issue entirely.
Default to ref for composable-internal state. It's safer to destructure, plays better with TypeScript, and auto-unwraps in templates. Reach for reactive only when you're managing a tightly coupled group of values that always travel together and never need to be passed individually to child components.