Why Vue composables still matter in 2024
Vue 3 made composables the primary way to share stateful logic across components. In 2024, they’ve matured into a powerful architecture pattern for building scalable, maintainable apps. Composables aren’t just a utility function you import anymore—they’re where your business rules live, where cross-cutting concerns like caching and persistence are orchestrated, and where you can tame the complexity of asynchronous server state.
Whether you’re using pure Composition API, Pinia, Nuxt, TanStack Query, or VueUse, advanced composables help you:
- Centralize state and side effects with predictable lifecycles.
- Isolate performance-heavy logic.
- Reuse complex flows (pagination, optimistic updates, debounced validation).
- Write testable, dependency-injected logic that doesn’t lock you into a specific UI or framework flavor.
This guide shows advanced composable patterns for 2024 with practical, production-ready examples and the reasoning behind them.
What makes a composable “advanced”?
Most composables wrap a few refs and a couple of watchers. Advanced composables go further:
- Encapsulate state, side effects, and cleanup with onScopeDispose.
- Provide a stable public API with strong TypeScript types.
- Handle async concerns: cancellations, race conditions, retries, optimistic updates.
- Support SSR and hydration.
- Integrate with global caches and createGlobalState to avoid duplicated state.
- Offer predictable error handling and extensibility.
Let’s start with the anatomy of a robust composable.
Anatomy of a robust composable
A good composable is explicit about:
- State (refs/reactive)
- Derived state (computed)
- Actions (functions)
- Side effects (watch/watchEffect)
- Cleanup (onScopeDispose)
Example: a minimal yet solid countdown composable.
// useCountdown.ts
import { ref, computed, onScopeDispose } from 'vue'
export function useCountdown(initial = 60, tickMs = 1000) {
const seconds = ref(initial)
const isActive = ref(false)
let timer: number | null = null
function start() {
if (isActive.value) return
isActive.value = true
timer = window.setInterval(() => {
if (seconds.value > 0) seconds.value--
else stop()
}, tickMs)
}
function stop() {
if (!isActive.value) return
isActive.value = false
if (timer != null) {
clearInterval(timer)
timer = null
}
}
function reset(value = initial) {
seconds.value = value
}
const progress = computed(() => 1 - seconds.value / initial)
onScopeDispose(stop)
return { seconds, isActive, progress, start, stop, reset }
}
Key details:
- No global singletons unless intended.
- Cleanup onScopeDispose guards against memory leaks.
- Stable, well-named API methods.
Shared state without a store: createGlobalState
When the same state is used across multiple components, you can share a single instance intentionally with createGlobalState from VueUse.
// useAuth.ts
import { ref, computed } from 'vue'
import { createGlobalState, useStorage } from '@vueuse/core'
type User = { id: string; email: string; name: string }
export const useAuth = createGlobalState(() => {
// Persist token (SSR-safe in VueUse; defers until client)
const token = useStorage<string | null>('auth:token', null)
const user = ref<User | null>(null)
const isAuthenticated = computed(() => !!token.value)
const isLoading = ref(false)
const error = ref<string | null>(null)
async function login(email: string, password: string) {
isLoading.value = true
error.value = null
try {
// Replace with your API
const res = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
})
if (!res.ok) throw new Error('Invalid credentials')
const data = await res.json()
token.value = data.token
user.value = data.user
} catch (e: any) {
token.value = null
user.value = null
error.value = e?.message ?? 'Login failed'
} finally {
isLoading.value = false
}
}
function logout() {
token.value = null
user.value = null
}
return { token, user, isAuthenticated, isLoading, error, login, logout }
})
Usage:
- Multiple components can call useAuth() and refer to the same state.
- Use this pattern sparingly; prefer local composables unless you need global sharing.
Actionable tips:
- If you need to mock in tests, wrap fetch in an injected repository (see “Dependency injection” below).
- Guard SSR-only code with import.meta.client when not using VueUse helpers.
Async state beyond fetch: cancelation, caching, optimistic updates
Handling async state is where composables truly shine. In 2024, two strong approaches stand out:
- Custom async composables for simple cases.
- TanStack Query (Vue Query) for server-state: caching, deduping, retries, optimistic updates.
Abortable fetch composable
Prevent race conditions when queries change fast (e.g., search box):
// useAbortableFetch.ts
import { ref, watch, onScopeDispose, toValue, type MaybeRefOrGetter } from 'vue'
export function useAbortableFetch<T>(url: MaybeRefOrGetter<string>, init?: RequestInit) {
const data = ref<T | null>(null)
const error = ref<Error | null>(null)
const loading = ref(false)
let controller: AbortController | null = null
async function execute() {
const u = toValue(url)
if (!u) return
controller?.abort()
controller = new AbortController()
loading.value = true
error.value = null
try {
const res = await fetch(u, { ...init, signal: controller.signal })
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`)
data.value = (await res.json()) as T
} catch (e: any) {
if (e.name !== 'AbortError') error.value = e
} finally {
loading.value = false
}
}
function abort() {
controller?.abort()
}
watch(() => toValue(url), execute, { immediate: true })
onScopeDispose(abort)
return { data, error, loading, execute, abort }
}
Benefits:
- Race conditions prevented via AbortController.
- SSR-safe as long as you only run it client-side.
Server-state with TanStack Query (v5)
For production-grade apps, TanStack Query gives you caching, retries, and optimistic updates with minimal code.
Composable for todos:
// useTodos.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/vue-query'
type Todo = { id: string; title: string; done: boolean }
async function fetchTodos(): Promise<Todo[]> {
const res = await fetch('/api/todos')
if (!res.ok) throw new Error('Failed to fetch todos')
return res.json()
}
async function createTodo(title: string): Promise<Todo> {
const res = await fetch('/api/todos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title }),
})
if (!res.ok) throw new Error('Failed to create todo')
return res.json()
}
export function useTodos() {
const qc = useQueryClient()
const todosQuery = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
staleTime: 60_000,
refetchOnWindowFocus: false,
})
const addTodo = useMutation({
mutationFn: createTodo,
onMutate: async (title: string) => {
await qc.cancelQueries({ queryKey: ['todos'] })
const previous = qc.getQueryData<Todo[]>(['todos']) ?? []
const optimistic: Todo = {
id: `tmp-${Math.random().toString(36).slice(2)}`,
title,
done: false,
}
qc.setQueryData<Todo[]>(['todos'], (old = []) => [...old, optimistic])
return { previous }
},
onError: (_err, _title, context) => {
if (context?.previous) qc.setQueryData(['todos'], context.previous)
},
onSettled: () => {
qc.invalidateQueries({ queryKey: ['todos'] })
},
})
return { todosQuery, addTodo }
}
Actionable advice:
- For data that changes on the server (lists, feeds), prefer TanStack Query over hand-rolled caching.
- For simple, one-off requests bound to component lifecycle, custom composables are fine.
Advanced form logic: validation, debounced autosave, submit contracts
Composable forms help you separate validation and change detection from the UI. Here’s a pattern with Zod (or any schema validator) and debounced autosave.
// useForm.ts
import { reactive, ref, computed, toRaw, onScopeDispose, watch } from 'vue'
import { useDebounceFn } from '@vueuse/core'
import { z } from 'zod'
type SubmitHandler<T> = (values: T) => Promise<void> | void
export function useForm<T extends object>(opts: {
schema: z.ZodSchema<T>
initial: T
autosave?: boolean
autosaveMs?: number
onAutosave?: SubmitHandler<T>
}) {
const { schema, initial, autosave = false, autosaveMs = 500, onAutosave } = opts
const values = reactive(structuredClone(initial)) as T
const errors = ref<Record<string, string[]>>({})
const submitting = ref(false)
const touched = ref(new Set<string>())
function markTouched(path: string) {
touched.value.add(path)
}
function validate(): boolean {
errors.value = {}
const result = schema.safeParse(values)
if (!result.success) {
for (const issue of result.error.issues) {
const path = issue.path.join('.') || 'form'
;(errors.value[path] ||= []).push(issue.message)
}
return false
}
return true
}
const isDirty = computed(
() => JSON.stringify(toRaw(values)) !== JSON.stringify(initial),
)
async function submit(fn: SubmitHandler<T>) {
if (!validate()) return
submitting.value = true
try {
await fn(structuredClone(toRaw(values)))
} finally {
submitting.value = false
}
}
function reset() {
Object.assign(values as any, structuredClone(initial))
errors.value = {}
touched.value.clear()
}
const autosaveFn = useDebounceFn(async () => {
if (!autosave || !onAutosave) return
if (!validate()) return
await onAutosave(structuredClone(toRaw(values)))
}, autosaveMs)
if (autosave && onAutosave) {
watch(values as any, () => autosaveFn(), { deep: true })
onScopeDispose(() => autosaveFn.cancel())
}
return {
values,
errors,
submitting,
isDirty,
touched,
markTouched,
validate,
submit,
reset,
}
}
Usage example:
// in component setup
const schema = z.object({
name: z.string().min(1),
email: z.string().email(),
})
const form = useForm({
schema,
initial: { name: '', email: '' },
autosave: true,
onAutosave: async (values) => {
await saveDraft(values) // your API
},
})
Tips:
- Keep submit handler outside composable to avoid coupling to a specific backend.
- Don’t mutate the initial object; use structuredClone for consistent dirty checks.
Type-safe event bus composable
When components are distant but you don’t need a global store, an event bus is perfect. Use mitt and TypeScript to enforce event contracts.
// useEventBus.ts
import mitt from 'mitt'
type Events = {
'cart:add': { productId: string; qty: number }
'cart:clear': void
'toast:show': { message: string; variant?: 'success' | 'error' | 'info' }
}
const emitter = mitt<Events>()
export function useEventBus() {
const on = emitter.on
const off = emitter.off
const emit = emitter.emit
return { on, off, emit }
}
Usage:
// Component A
const { emit } = useEventBus()
emit('toast:show', { message: 'Saved!', variant: 'success' })
// Component B
const { on, off } = useEventBus()
on('toast:show', (p) => showToast(p.message, p.variant))
Best practices:
- Namespaces your events (e.g., cart:, toast:).
- Clean up listeners on component unmount when creating ad-hoc listeners.
Offloading heavy work: Web Worker composables
Move CPU-heavy tasks off the main thread via @vueuse/core’s useWebWorkerFn.
// useHashWorker.ts
import { useWebWorkerFn } from '@vueuse/core'
import { ref } from 'vue'
export function useHashWorker() {
const { workerFn, workerStatus, post, terminate } = useWebWorkerFn(
// runs in worker context
async (text: string) => {
const msgUint8 = new TextEncoder().encode(text)
const hashBuffer = await crypto.subtle.digest('SHA-256', msgUint8)
const hashArray = Array.from(new Uint8Array(hashBuffer))
return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('')
},
{ timeout: 10_000 },
)
const hash = ref<string | null>(null)
const error = ref<Error | null>(null)
const running = ref(false)
async function compute(text: string) {
running.value = true
error.value = null
try {
hash.value = await post(text)
} catch (e: any) {
error.value = e
} finally {
running.value = false
}
}
return { hash, running, error, compute, status: workerStatus, terminate, workerFn }
}
Practical uses:
- Image processing, data transforms, encryption.
- Keep the UI responsive while doing heavy work.
Undo/redo and time travel as a composable
Add user-friendly undo/redo to complex editors or configuration UIs.
// useHistory.ts
import { ref, computed } from 'vue'
export function useHistory<T>(initial: T) {
const past = ref<T[]>([])
const present = ref<T>(structuredClone(initial))
const future = ref<T[]>([])
function set(next: T) {
past.value.push(structuredClone(present.value))
present.value = structuredClone(next)
future.value = []
}
function undo() {
if (!past.value.length) return
future.value.unshift(structuredClone(present.value))
present.value = past.value.pop()!
}
function redo() {
if (!future.value.length) return
past.value.push(structuredClone(present.value))
present.value = future.value.shift()!
}
const canUndo = computed(() => past.value.length > 0)
const canRedo = computed(() => future.value.length > 0)
return { state: present, set, undo, redo, canUndo, canRedo }
}
Combine with your form or editor to get instant undo/redo.
Dependency injection for business logic
Inject repositories into composables to decouple from transport details, making testing and swapping implementations easy.
// repo.ts
import type { InjectionKey } from 'vue'
import { inject, provide } from 'vue'
export interface UserRepo {
getById(id: string): Promise<{ id: string; email: string }>
save(input: { id: string; email: string }): Promise<void>
}
export const UserRepoKey: InjectionKey<UserRepo> = Symbol('UserRepo')
export function provideUserRepo(repo: UserRepo) {
provide(UserRepoKey, repo)
}
export function useUserRepo() {
const repo = inject(UserRepoKey)
if (!repo) throw new Error('UserRepo not provided')
return repo
}
Composable using the repo:
// useUserProfile.ts
import { ref } from 'vue'
import { useUserRepo } from './repo'
export function useUserProfile(id: string) {
const repo = useUserRepo()
const user = ref<{ id: string; email: string } | null>(null)
const loading = ref(false)
const error = ref<string | null>(null)
async function load() {
loading.value = true
error.value = null
try {
user.value = await repo.getById(id)
} catch (e: any) {
error.value = e?.message ?? 'Failed to load user'
} finally {
loading.value = false
}
}
async function save(email: string) {
if (!user.value) return
await repo.save({ id: user.value.id, email })
}
return { user, loading, error, load, save }
}
In tests, provide a mock; in production, provide a real implementation.
Composables and Pinia: when to use each
- Use composables for feature-level logic that can be instantiated multiple times (e.g., useForm, usePagination, useToggle).
- Use Pinia for app-wide or domain-level state that benefits from devtools, persistence, and mutation tracking (e.g., auth, cart, preferences).
- Mix them: a composable can read/write a Pinia store; a store can expose composable-like helpers.
Example: wrapping a Pinia store in a composable for a clearer API boundary.
// useCart.ts
import { defineStore, storeToRefs } from 'pinia'
import { computed } from 'vue'
const useCartStore = defineStore('cart', {
state: () => ({ items: [] as { id: string; title: string; qty: number; price: number }[] }),
actions: {
add(id: string, title: string, price: number, qty = 1) {
const found = this.items.find((i) => i.id === id)
if (found) found.qty += qty
else this.items.push({ id, title, qty, price })
},
clear() {
this.items = []
},
},
})
export function useCart() {
const store = useCartStore()
const { items } = storeToRefs(store)
const count = computed(() => items.value.reduce((n, i) => n + i.qty, 0))
const total = computed(() => items.value.reduce((n, i) => n + i.qty * i.price, 0))
return { items, count, total, add: store.add, clear: store.clear }
}
This pattern gives you a stable public API while preserving Pinia’s benefits.
Performance and lifecycle patterns to adopt in 2024
- Prefer watch over watchEffect for explicit dependencies. Use watchEffect for prototyping or truly dynamic dependencies.
- Use flush: 'post' when a watcher relies on DOM after updates.
- Use shallowRef for large, immutable structures (maps, third-party instances) to avoid deep reactivity overhead.
- Use markRaw to store non-reactive instances in reactive containers.
- Always clean up: onScopeDispose for intervals, events, and subscriptions.
- Avoid creating watchers inside loops; batch work or use an array of refs and a single watcher.
- If you expose heavy computed values, consider memoization and document when they update.
Example: Avoiding deep reactivity on a Map.
import { shallowRef, onScopeDispose } from 'vue'
export function useSocket(url: string) {
const socket = shallowRef<WebSocket | null>(null)
function connect() {
if (socket.value) return
const ws = new WebSocket(url)
socket.value = ws
ws.addEventListener('open', () => console.log('open'))
ws.addEventListener('close', () => console.log('closed'))
}
function send(msg: string) {
socket.value?.send(msg)
}
function disconnect() {
socket.value?.close()
socket.value = null
}
onScopeDispose(disconnect)
return { socket, connect, send, disconnect }
}
SSR and Nuxt considerations
- Accessing window/localStorage must be guarded: if (import.meta.client) { ... } in Vite-based projects.
- VueUse composables like useStorage and useCookie handle SSR gracefully—prefer them.
- For server data in Nuxt, consider a composable that uses useAsyncData or injects event/request context.
- Ensure deterministic initial state to avoid hydration mismatches; avoid random values in default state unless gated by client-only logic.
SSR-safe local storage fallback:
import { ref, onMounted } from 'vue'
export function useClientStorage(key: string, defaultValue: string) {
const value = ref(defaultValue)
onMounted(() => {
try {
const saved = localStorage.getItem(key)
if (saved != null) value.value = saved
} catch {}
})
function save(v: string) {
value.value = v
if (import.meta.client) localStorage.setItem(key, v)
}
return { value, save }
}
Real-world composition: a smart search composable
Requirements:
- Debounced input.
- Sync query to URL.
- Abort stale requests.
- Cache last result to sessionStorage.
// useSmartSearch.ts
import { computed, ref, watch, toValue } from 'vue'
import { useDebounceFn, useSessionStorage } from '@vueuse/core'
import { useAbortableFetch } from './useAbortableFetch'
type Result = { id: string; title: string }[]
export function useSmartSearch(routeQuery: () => string, setRouteQuery: (q: string) => void) {
const q = ref(routeQuery() || '')
const cache = useSessionStorage<Record<string, Result>>('search:cache', {})
const url = computed(() => (q.value ? `/api/search?q=${encodeURIComponent(q.value)}` : ''))
const { data, error, loading, execute, abort } = useAbortableFetch<Result>(url)
const debouncedFetch = useDebounceFn(async () => {
const key = q.value.trim()
if (!key) return
if (cache.value[key]) {
// cache hit
data.value = cache.value[key]
return
}
await execute()
if (data.value) cache.value[key] = data.value
}, 300)
watch(q, (next) => {
setRouteQuery(next)
if (!next.trim()) {
abort()
data.value = null
return
}
debouncedFetch()
}, { immediate: true })
function setQuery(next: string) {
q.value = next
}
return { q, results: data, error, loading, setQuery }
}
Example usage in a component:
<script setup lang="ts">
import { useRoute, useRouter } from 'vue-router'
import { useSmartSearch } from '@/composables/useSmartSearch'
const router = useRouter()
const route = useRoute()
const { q, results, loading, error, setQuery } = useSmartSearch(
() => (route.query.q as string) ?? '',
(val) => router.replace({ query: { ...route.query, q: val || undefined } }),
)
</script>
<template>
<input :value="q" @input="setQuery(($event.target as HTMLInputElement).value)" placeholder="Search..." />
<div v-if="loading">Loading…</div>
<div v-else-if="error">Error: {{ error.message }}</div>
<ul v-else>
<li v-for="r in results || []" :key="r.id">{{ r.title }}</li>
</ul>
</template>
This exemplifies multiple advanced concerns in a clean, testable API.
Testing composables effectively
- Test the public API: returned refs, computed, and actions.
- Mock network or injected repositories.
- Use fake timers for debounced logic.
Example with Vitest:
// useHistory.spec.ts
import { describe, it, expect, vi } from 'vitest'
import { useHistory } from './useHistory'
describe('useHistory', () => {
it('pushes states and undoes', () => {
const { state, set, undo, redo, canUndo, canRedo } = useHistory({ count: 0 })
expect(state.value.count).toBe(0)
set({ count: 1 })
set({ count: 2 })
expect(canUndo.value).toBe(true)
undo()
expect(state.value.count).toBe(1)
redo()
expect(state.value.count).toBe(2)
expect(canRedo.value).toBe(false)
})
})
For debounced logic:
import { useForm } from './useForm'
import { z } from 'zod'
it('autosaves after debounce', async () => {
vi.useFakeTimers()
const save = vi.fn()
const form = useForm({
schema: z.object({ name: z.string() }),
initial: { name: '' },
autosave: true,
onAutosave: save,
})
form.values.name = 'Alice'
vi.advanceTimersByTime(500)
expect(save).toHaveBeenCalledWith({ name: 'Alice' })
vi.useRealTimers()
})
API design checklist for composables
- Name: useMeaningfulThing (e.g., usePaginatedUsers).
- Inputs: accept MaybeRefOrGetter where sensible; use toValue internally.
- Outputs: return a flat object of refs, computeds, and functions. Avoid nesting unnecessarily.
- Cleanup: always call onScopeDispose for side effects.
- Errors: expose a typed error ref; don’t throw unless intended.
- SSR: document client-only behavior; guard browser APIs.
- Types: expose robust TypeScript types; avoid any leaks.
- Stability: avoid returning new object instances on each call if they can be cached; keep function identities stable.
- Documentation: mention when to call execute/refetch; describe lazy vs eager behavior.
Common pitfalls and how to avoid them
- Memory leaks: forgetting to remove event listeners or intervals. Always attach a disposer to onScopeDispose.
- Racing requests: not canceling fetch when queries change. Use AbortController or a query library.
- Overwatching: deep: true on large objects triggers too often. Prefer precise watchers or manual triggers.
- State coupling: mixing UI concerns (e.g., modal DOM) inside domain composables. Keep UI separate; return a boolean for isOpen and let the component render.
- Singletons by accident: returning static module-level refs without createGlobalState. Ensure each call creates a new instance unless you explicitly want global state.
- SSR mismatches: reading from window/localStorage during setup on server. Defer reads to onMounted or use VueUse.
Quick wins you can implement today
- Wrap multi-use logic in composables with clear contracts and types.
- Add cancellation to all user-driven fetches (search, filters).
- Use createGlobalState for auth/session state; useStorage for persistence.
- Introduce a type-safe event bus for cross-component communication.
- Add optimistic updates with TanStack Query for snappier UX.
- Use shallowRef and markRaw for non-reactive heavy instances (Map, WebSocket, third-party SDKs).
- Write at least one unit test per mission-critical composable.
Putting it all together: a pattern library of composables
As your app grows, maintain a small library of well-documented composables for:
- Network: useAbortableFetch, useRetry, useApi (with injected repo).
- Forms: useForm, useField, useStepper, useWizard.
- State: useAuth (createGlobalState), usePreferences (useStorage), useFeatureFlags.
- UX: useEventBus, useToast (event-driven), useModal (headless).
- Data: usePaginatedQuery, useInfiniteScroll (with IntersectionObserver), useMutation with optimistic updates.
- Performance: useWebWorker for heavy tasks, useThrottleFn/useDebounceFn for rapid user input.
- Utilities: useHistory for undo/redo, useClipboard, useContextMenu.
Every composable should be small, composable in itself, and easy to test.
Final thoughts
Advanced composables are the backbone of modern Vue applications. In 2024, the ecosystem around them—VueUse, TanStack Query, Pinia, Nuxt—makes it easier than ever to build clean, testable, and high-performance features. Start by refactoring your most complex component logic into composables with clear APIs. Add cancellation, caching, and persistence where it improves UX. And adopt the patterns above to keep your codebase maintainable as your product grows.
Your next refactor doesn’t need to be a rewrite—move logic into composables incrementally, strengthen the contracts with types and tests, and enjoy a calmer, more predictable codebase.