A composable is a plain function that uses Composition API features (ref, computed, watch, lifecycle hooks) to encapsulate a piece of stateful logic and return it. It is the Vue 3 answer to mixins and renderless components, and it is how professional codebases avoid copy-pasting the same fetching, form or event-listener code into dozens of components. After this lesson you will write composables that accept flexible inputs, clean up after themselves, and compose with each other.
Start from logic that appears in more than one component. Tracking the mouse position is the classic example:
// src/composables/useMouse.js
import { ref, onMounted, onUnmounted } from 'vue'
export function useMouse() {
const x = ref(0)
const y = ref(0)
function update(event) {
x.value = event.pageX
y.value = event.pageY
}
onMounted(() => window.addEventListener('mousemove', update))
onUnmounted(() => window.removeEventListener('mousemove', update))
return { x, y }
}Any component can now write const { x, y } = useMouse() in <script setup>. Because the composable registers lifecycle hooks, it must be called synchronously inside setup (or inside another composable), where Vue knows which component instance is active. Each call creates independent state; two components using useMouse do not share x.
useSomething and keep one composable per file under src/composables/.reactive object, so callers can destructure without losing reactivity.toValue().A composable that only accepts a static value cannot react when the caller's state changes. toValue unwraps refs and calls getter functions, and watchEffect re-runs when those change:
// src/composables/useFetch.js
import { ref, watchEffect, toValue, onWatcherCleanup } from 'vue'
export function useFetch(url) {
const data = ref(null)
const error = ref(null)
const loading = ref(false)
watchEffect(async () => {
const controller = new AbortController()
onWatcherCleanup(() => controller.abort())
loading.value = true
error.value = null
try {
const res = await fetch(toValue(url), { signal: controller.signal })
if (!res.ok) throw new Error(`HTTP ${res.status}`)
data.value = await res.json()
} catch (e) {
if (e.name !== 'AbortError') error.value = e
} finally {
loading.value = false
}
})
return { data, error, loading }
}All three call styles now work and the last two refetch automatically:
useFetch('/api/posts')
useFetch(urlRef)
useFetch(() => `/api/posts/${props.id}`)Composables call other composables. A useUser(id) composable can build on useFetch, and a useLocalStorage(key, initial) composable can wrap a ref with a deep watch that serializes to localStorage. Because each returns refs, the pieces snap together:
export function useUser(id) {
const { data: user, loading, error } = useFetch(() => `/api/users/${toValue(id)}`)
const displayName = computed(() =>
user.value ? `${user.value.first} ${user.value.last}` : ''
)
return { user, displayName, loading, error }
}For logic that must be shared as a single instance across the whole app (current user, theme), create the state at module level outside the function and return it. That is a lightweight alternative to a store; use Pinia once the state needs devtools, plugins or SSR support.
Before writing a composable, check VueUse (npm install @vueuse/core), a collection of over 200 tested composables: useLocalStorage, useDebounceFn, useIntersectionObserver, useMediaQuery, useEventListener, useClipboard and more. It is tree-shakeable, typed, and follows the conventions above, so reading its source is also an excellent way to learn idiomatic patterns.
await; lifecycle hooks then have no active instance and are silently ignored.reactive({ ... }) and having callers destructure it.Why must composables that use lifecycle hooks be called synchronously in `<script setup>`?
useX function that encapsulates reactive state and logic and returns refs and functions.toValue; use watchEffect to react to changes.Next lesson: Provide and Inject — share data with deeply nested components without prop drilling.