Composables
Building reusable custom composables in Vue 3 with proper typing, lifecycle management, and state sharing
You are an expert in designing and implementing custom Vue 3 composables that encapsulate reusable stateful logic. ## Key Points - **Encapsulation** — a composable owns its own reactive state, watchers, and lifecycle hooks - **Return contract** — composables return a well-typed object of refs, computeds, and functions - **Lifecycle binding** — composables called inside `setup()` / `<script setup>` are tied to the component's lifecycle - **Shared vs. scoped state** — state declared inside the function is per-component; state declared outside is shared (singleton) 1. **Name with `use` prefix** — `useMouse`, `useFetch`, `useAuth`. This signals the function uses Composition API internals. 2. **Accept `MaybeRefOrGetter` for inputs** — use `toValue()` to unwrap, allowing callers to pass plain values, refs, or getters. 3. **Return refs, not reactive** — returning individual refs lets callers destructure without losing reactivity. Wrap mutable state with `readonly()` when consumers should not mutate. 4. **Handle cleanup** — register `onUnmounted` or use `onCleanup` in `watchEffect` for any subscriptions, timers, or event listeners. 5. **Keep composables focused** — one concern per composable. Compose multiple composables together rather than building monoliths. 6. **Co-locate with usage** — place composables in a `composables/` directory. If only one component uses it, co-locate next to that component. 7. **Type the return value** — define an explicit return interface for complex composables so consumers have clear documentation. - **Unintended shared state** — declaring refs outside the function makes them singletons. This is a feature when intended (e.g., auth) but a bug when each component should have its own copy.
skilldb get vue-skills/ComposablesFull skill: 217 linesCustom Composables — Vue.js
You are an expert in designing and implementing custom Vue 3 composables that encapsulate reusable stateful logic.
Core Philosophy
Composables are the fundamental unit of logic reuse in Vue 3, replacing mixins and renderless components with something far more transparent. The guiding principle is encapsulation with explicit contracts: a composable owns its reactive state, watchers, and lifecycle hooks internally, while exposing a clear, typed return object that consumers can destructure and use without understanding the internals. This mirrors the Unix philosophy of small, focused tools that compose well together.
Thinking in composables means thinking in terms of concerns, not components. A component might need mouse tracking, data fetching, and debounced search — each of these is a discrete concern that deserves its own composable. When you extract logic into a composable, you make it testable in isolation, reusable across components, and readable at the call site. The composition happens naturally in <script setup>, where calling multiple use* functions reads like a declaration of what the component needs.
State ownership is the design decision that matters most. Declaring reactive state inside the composable function gives each caller its own copy; declaring it outside creates a singleton shared across the application. This is not a default to accept blindly — it is an architectural choice that should be made intentionally for each composable based on whether the state represents per-instance data or global application state.
Anti-Patterns
-
The Kitchen-Sink Composable — cramming unrelated concerns into a single
useApp()oruseUtils()composable that returns dozens of values. This defeats the purpose of composables and makes dependency tracking opaque. Split by concern. -
Async Top-Level Await in Composables — using
awaitat the top level of a composable causes lifecycle hooks registered after theawaitto lose their component instance binding. Always register all lifecycle hooks synchronously before anyawait. -
Leaking Internal Refs — returning mutable refs that consumers should not modify. Wrap shared state with
readonly()and provide explicit mutation methods to maintain encapsulation and prevent hidden coupling between components. -
Composable Calls Outside Setup — invoking composables in utility functions, event handlers, or
setTimeoutcallbacks. Lifecycle hooks inside the composable silently fail because there is no active component instance. Composables must be called synchronously duringsetup(). -
Implicit Singleton Bugs — declaring refs outside the function for convenience without realizing every component that calls the composable shares the same state. Unless global sharing is the explicit goal, always declare state inside the function body.
Overview
Composables are functions that leverage Vue's Composition API to encapsulate and reuse stateful logic across components. By convention they are named use* and return reactive state, computed values, and methods. They are the primary code-reuse mechanism in Vue 3, replacing mixins and renderless components.
Core Concepts
- Encapsulation — a composable owns its own reactive state, watchers, and lifecycle hooks
- Return contract — composables return a well-typed object of refs, computeds, and functions
- Lifecycle binding — composables called inside
setup()/<script setup>are tied to the component's lifecycle - Shared vs. scoped state — state declared inside the function is per-component; state declared outside is shared (singleton)
Implementation Patterns
Basic Composable
// composables/useMouse.ts
import { ref, onMounted, onUnmounted } from 'vue'
export function useMouse() {
const x = ref(0)
const y = ref(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 }
}
Async Data Fetching Composable
// composables/useFetch.ts
import { ref, watchEffect, toValue, type MaybeRefOrGetter } from 'vue'
interface UseFetchReturn<T> {
data: Ref<T | null>
error: Ref<Error | null>
isLoading: Ref<boolean>
refresh: () => Promise<void>
}
export function useFetch<T = unknown>(
url: MaybeRefOrGetter<string>
): UseFetchReturn<T> {
const data = ref<T | null>(null) as Ref<T | null>
const error = ref<Error | null>(null)
const isLoading = ref(false)
async function doFetch() {
isLoading.value = true
error.value = null
try {
const res = await fetch(toValue(url))
if (!res.ok) throw new Error(`HTTP ${res.status}`)
data.value = await res.json()
} catch (e) {
error.value = e instanceof Error ? e : new Error(String(e))
} finally {
isLoading.value = false
}
}
watchEffect(() => {
// toValue(url) inside doFetch is tracked here
doFetch()
})
return { data, error, isLoading, refresh: doFetch }
}
Composable Accepting Reactive Input
// composables/useDebounce.ts
import { ref, watch, type Ref } from 'vue'
export function useDebounce<T>(source: Ref<T>, delay = 300): Ref<T> {
const debounced = ref(source.value) as Ref<T>
let timeout: ReturnType<typeof setTimeout>
watch(source, (val) => {
clearTimeout(timeout)
timeout = setTimeout(() => {
debounced.value = val
}, delay)
})
return debounced
}
Shared Singleton State
// composables/useAuth.ts
import { ref, computed, readonly } from 'vue'
interface User {
id: string
name: string
role: string
}
// Declared outside — shared across all components that call useAuth()
const currentUser = ref<User | null>(null)
const token = ref<string | null>(null)
export function useAuth() {
const isAuthenticated = computed(() => !!token.value)
const isAdmin = computed(() => currentUser.value?.role === 'admin')
async function login(email: string, password: string) {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
})
const data = await res.json()
token.value = data.token
currentUser.value = data.user
}
function logout() {
token.value = null
currentUser.value = null
}
return {
user: readonly(currentUser),
isAuthenticated,
isAdmin,
login,
logout,
}
}
Composable with Cleanup
// composables/useEventSource.ts
import { ref, onUnmounted } from 'vue'
export function useEventSource(url: string) {
const data = ref<string>('')
const status = ref<'connecting' | 'open' | 'closed'>('connecting')
const es = new EventSource(url)
es.onopen = () => { status.value = 'open' }
es.onmessage = (event) => { data.value = event.data }
es.onerror = () => {
status.value = 'closed'
es.close()
}
onUnmounted(() => es.close())
return { data, status }
}
Best Practices
- Name with
useprefix —useMouse,useFetch,useAuth. This signals the function uses Composition API internals. - Accept
MaybeRefOrGetterfor inputs — usetoValue()to unwrap, allowing callers to pass plain values, refs, or getters. - Return refs, not reactive — returning individual refs lets callers destructure without losing reactivity. Wrap mutable state with
readonly()when consumers should not mutate. - Handle cleanup — register
onUnmountedor useonCleanupinwatchEffectfor any subscriptions, timers, or event listeners. - Keep composables focused — one concern per composable. Compose multiple composables together rather than building monoliths.
- Co-locate with usage — place composables in a
composables/directory. If only one component uses it, co-locate next to that component. - Type the return value — define an explicit return interface for complex composables so consumers have clear documentation.
Common Pitfalls
- Calling composables outside of
setup()— lifecycle hooks inside composables only work when called during component setup. CallinguseMouse()in a plain utility function silently skipsonMounted/onUnmounted. - Unintended shared state — declaring refs outside the function makes them singletons. This is a feature when intended (e.g., auth) but a bug when each component should have its own copy.
- Forgetting to track reactive inputs — if the composable receives a ref but reads
.valueonly once (not inside awatchorcomputed), it will not react to changes. - Returning raw reactive objects — if you return
reactive({...})the caller cannot destructure it. Return individual refs or usetoRefs(). - Async composables losing context — composables that
awaitat the top level lose the component instance. Register all lifecycle hooks before anyawait.
Install this skill directly: skilldb add vue-skills
Related Skills
Composition API
Patterns and best practices for Vue 3 Composition API including refs, reactive, computed, watchers, and lifecycle hooks
Nuxt Routing
Nuxt 3 file-based routing, dynamic routes, layouts, middleware, and navigation patterns
Nuxt Server
Nuxt 3 server routes, API endpoints, server middleware, and Nitro engine patterns for full-stack development
Pinia
Pinia state management for Vue 3 including store design, actions, getters, plugins, and SSR hydration
Provide Inject
Vue 3 provide/inject for dependency injection, deeply nested component communication, and plugin design
Vue Router
Vue Router 4 advanced patterns including navigation guards, lazy loading, scroll behavior, and typed routes