Composables: Reusable Logic with the Composition API

Intermediate
13 min

Composables: Reusable Logic with the Composition API

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.

Your First Composable

Start from logic that appears in more than one component. Tracking the mouse position is the classic example:

javascript
// 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.

Conventions That Make Composables Predictable

  • Name them useSomething and keep one composable per file under src/composables/.
  • Return an object of refs, not a reactive object, so callers can destructure without losing reactivity.
  • Accept inputs as plain values, refs or getters, and normalize them with toValue().
  • Register cleanup for every listener, timer or subscription you create.
  • Keep them free of rendering concerns; they return data and functions, never markup.

Accepting Reactive Inputs

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:

javascript
// 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:

javascript
useFetch('/api/posts') useFetch(urlRef) useFetch(() => `/api/posts/${props.id}`)

Composing Composables

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:

javascript
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.

VueUse

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.

Common mistakes

  • Calling a composable inside an event handler or after an await; lifecycle hooks then have no active instance and are silently ignored.
  • Returning reactive({ ... }) and having callers destructure it.
  • Forgetting to remove listeners, which leaks memory every time the component mounts.
Quick Quiz
Question 1 of 2

Why must composables that use lifecycle hooks be called synchronously in `<script setup>`?

Key Takeaways

  • A composable is a useX function that encapsulates reactive state and logic and returns refs and functions.
  • Call composables synchronously inside setup so lifecycle hooks and cleanup attach to the right instance.
  • Accept plain values, refs or getters and normalize with toValue; use watchEffect to react to changes.
  • Always clean up listeners, timers and requests.
  • Compose composables together, and reuse VueUse before writing your own.

Next lesson: Provide and Inject — share data with deeply nested components without prop drilling.

Composables: Reusable Logic with the Composition API - Vue.js | CodeYourCraft | CodeYourCraft