Vue 3 Composables: The Reusability Pattern That Changes Everything
A deep-dive into Vue 3 composables — how to write them well, when to use them vs components or Pinia, real patterns from production apps, and the mistakes to avoid.

James Ross Jr.
Strategic Systems Architect & Enterprise Software Developer
Composables are the single most important pattern in Vue 3, and most developers are not using them to their full potential. I see codebases with composables that are little more than named collections of refs — none of the cross-component reuse, none of the lifecycle encapsulation, none of the abstraction power that makes the pattern valuable.
This guide is about composables done correctly: when they genuinely help, what makes them well-designed, and the patterns from real production applications.
What Makes a Good Composable
A composable is a function that uses Vue's Composition API to encapsulate stateful logic. The distinguishing feature is that it can use reactive state, computed properties, lifecycle hooks, and watchers — and all of those things get properly cleaned up when the component using the composable is unmounted.
A good composable does one thing well. It has a clear name that starts with use and describes the logical concern. It returns only what callers need — not everything it uses internally. It handles its own cleanup.
A bad composable is a bag of loosely related refs and functions that happened to be grouped together. That is not reusability, that is just organization.
The Anatomy of a Well-Designed Composable
// composables/useWebSocket.ts
interface WebSocketOptions {
url: string
onMessage?: (data: unknown) => void
reconnectInterval?: number
}
export function useWebSocket(options: WebSocketOptions) {
const { url, onMessage, reconnectInterval = 3000 } = options
const status = ref<'connecting' | 'connected' | 'disconnected' | 'error'>('disconnected')
const lastMessage = ref<unknown>(null)
let socket: WebSocket | null = null
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
function connect() {
status.value = 'connecting'
socket = new WebSocket(url)
socket.onopen = () => {
status.value = 'connected'
}
socket.onmessage = (event) => {
const data = JSON.parse(event.data)
lastMessage.value = data
onMessage?.(data)
}
socket.onclose = () => {
status.value = 'disconnected'
if (reconnectInterval > 0) {
reconnectTimer = setTimeout(connect, reconnectInterval)
}
}
socket.onerror = () => {
status.value = 'error'
}
}
function send(data: unknown) {
if (socket?.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify(data))
}
}
function disconnect() {
if (reconnectTimer) clearTimeout(reconnectTimer)
reconnectInterval = 0 // prevent reconnection
socket?.close()
}
// Automatically connect when used in a component
onMounted(connect)
// Automatically disconnect when component unmounts
onUnmounted(disconnect)
return {
status: readonly(status),
lastMessage: readonly(lastMessage),
send,
disconnect,
connect,
}
}
This composable:
- Has a single, clear responsibility (WebSocket connection management)
- Handles its own lifecycle (connects on mount, disconnects on unmount)
- Exposes a minimal surface area (only what callers need)
- Returns reactive state as
readonlyto prevent callers from bypassing the composable's logic - Is fully self-contained — the WebSocket logic does not leak into the component
Composable Patterns From Production
Async Data With Abort
Any composable that fetches data should support aborting in-flight requests when the component unmounts or the input changes:
export function useUser(userId: MaybeRefOrGetter<string>) {
const user = ref<User | null>(null)
const loading = ref(false)
const error = ref<string | null>(null)
watchEffect(async (onCleanup) => {
const controller = new AbortController()
onCleanup(() => controller.abort())
loading.value = true
error.value = null
try {
const id = toValue(userId)
user.value = await $fetch(`/api/users/${id}`, {
signal: controller.signal,
})
} catch (e) {
if (e instanceof Error && e.name !== 'AbortError') {
error.value = e.message
}
} finally {
loading.value = false
}
})
return { user: readonly(user), loading: readonly(loading), error: readonly(error) }
}
The onCleanup callback inside watchEffect runs when the effect re-runs (because userId changed) or when the component unmounts. Aborting previous requests prevents race conditions where a fast second request resolves before a slow first request, leaving the old data on screen.
Local Storage Sync
export function useLocalStorage<T>(key: string, defaultValue: T) {
const storedValue = localStorage.getItem(key)
const parsed = storedValue ? JSON.parse(storedValue) : defaultValue
const value = ref<T>(parsed)
watch(value, (newValue) => {
localStorage.setItem(key, JSON.stringify(newValue))
}, { deep: true })
return value
}
Usage:
const theme = useLocalStorage<'light' | 'dark'>('theme', 'light')
// Changing theme.value automatically persists to localStorage
Intersection Observer (Lazy Loading)
export function useIntersectionObserver(
target: MaybeRefOrGetter<Element | null>,
callback: IntersectionObserverCallback,
options: IntersectionObserverInit = {}
) {
const isIntersecting = ref(false)
let observer: IntersectionObserver | null = null
const stopObserving = watchEffect(() => {
const el = toValue(target)
if (!el) return
observer = new IntersectionObserver((entries) => {
isIntersecting.value = entries[0]?.isIntersecting ?? false
callback(entries, observer!)
}, options)
observer.observe(el)
})
onUnmounted(() => {
observer?.disconnect()
stopObserving()
})
return { isIntersecting: readonly(isIntersecting) }
}
Debounced Value
export function useDebouncedRef<T>(value: MaybeRefOrGetter<T>, delay = 300) {
const debouncedValue = ref<T>(toValue(value))
let timeout: ReturnType<typeof setTimeout>
watch(
() => toValue(value),
(newValue) => {
clearTimeout(timeout)
timeout = setTimeout(() => {
debouncedValue.value = newValue
}, delay)
}
)
onUnmounted(() => clearTimeout(timeout))
return readonly(debouncedValue)
}
Usage in a search component:
<script setup lang="ts">
const searchInput = ref('')
const debouncedSearch = useDebouncedRef(searchInput, 400)
// Only fires the API call after typing stops for 400ms
const { data: results } = useAsyncData(
() => `search-${debouncedSearch.value}`,
() => $fetch(`/api/search?q=${debouncedSearch.value}`),
{ watch: [debouncedSearch] }
)
</script>
When Not to Use a Composable
Not every piece of logic needs to be a composable. The overhead of creating a composable — naming it, designing its API, writing its documentation — is only worth it when the logic is genuinely reused or when the encapsulation is meaningful.
Keep logic inline in the component when:
- It is only used in one place and unlikely to be reused
- It is simple enough that a composable adds more indirection than clarity
- It is tightly coupled to that specific component's template logic
Move to a composable when:
- The same logic appears in two or more components
- The logic involves lifecycle hooks that need cleanup
- The logic is complex enough that the component gets hard to read
- The logic is independently testable and tested
Testing Composables
Composables are the most testable part of a Vue application. They are just functions — no DOM needed for most of them:
import { describe, it, expect } from 'vitest'
describe('useDebouncedRef', () => {
it('debounces value changes', async () => {
const source = ref('initial')
const debounced = useDebouncedRef(source, 100)
expect(debounced.value).toBe('initial')
source.value = 'changed'
expect(debounced.value).toBe('initial') // Not yet updated
await new Promise(resolve => setTimeout(resolve, 150))
expect(debounced.value).toBe('changed') // Now updated
})
})
The Composition API's design makes composables naturally testable. The reactive state is explicit, the dependencies are clear, and the lifecycle hooks can be triggered programmatically in tests.
Naming and Documentation
A composable's name is its primary documentation. useFetch tells you more than useData. useIntersectionObserver tells you more than useVisible. useLocalStorage tells you more than useStorage.
For complex composables, add a JSDoc comment with an example:
/**
* Syncs a reactive value with localStorage.
*
* @param key - The localStorage key to use
* @param defaultValue - The value to use when the key is not set
* @returns A writable ref that automatically persists to localStorage
*
* @example
* const theme = useLocalStorage('theme', 'light')
* theme.value = 'dark' // Automatically persisted
*/
export function useLocalStorage<T>(key: string, defaultValue: T) {
Composables are the building blocks of a well-structured Vue 3 application. Design them with the same care you would give any public API — they will be called from many places over the lifetime of your project.
Working on a Vue 3 or Nuxt codebase and want a review of your composable patterns or state management approach? I am happy to help — book a call at calendly.com/jamesrossjr.