Skip to content
Deep diveAdvanced Vue

Composables: the rules that keep them reusable

Any function that calls ref() can be called a composable. The ones you can reuse in another component, or another project, follow a handful of rules.

vuejs.school team18 Jun 20266 min readVue 3.5

A composable is a function that uses Vue's Composition API to hold some state and logic, so several components can share it. Writing one is easy. Writing one that still works when a second component calls it, when the input is a ref instead of a string, or when the component unmounts halfway through a request takes a few rules.

Most of them come from the composables guide in the Vue docs. Here they are applied one at a time to a tangled search component until it splits into two composables, and the examples show what breaks when you skip one.

A search component with two jobs and a race

A product search box. It fetches results as you type, and pressing / anywhere on the page focuses the input.

src/components/ProductSearch.vue
<script setup lang="ts">
import { onMounted, onUnmounted, ref, useTemplateRef, watch } from 'vue'

const query = ref('')
const results = ref<{ id: number; name: string }[]>([])
const loading = ref(false)
const input = useTemplateRef<HTMLInputElement>('input')

watch(query, async (q) => {
  loading.value = true
  const res = await fetch(`/api/products?q=${encodeURIComponent(q)}`)
  results.value = await res.json()
  loading.value = false
})

function onKeydown(event: KeyboardEvent) {
  if (event.key === '/' && document.activeElement !== input.value) {
    event.preventDefault()
    input.value?.focus()
  }
}
onMounted(() => window.addEventListener('keydown', onKeydown))
onUnmounted(() => window.removeEventListener('keydown', onKeydown))
</script>

<template>
  <input ref="input" v-model="query" type="search" placeholder="Search (press /)" />
  <p v-if="loading">Searching…</p>
  <ul v-else>
    <li v-for="p in results" :key="p.id">{{ p.name }}</li>
  </ul>
</template>

It works, mostly. Three things need fixing:

  • Two unrelated jobs share one script, so the keyboard shortcut cannot be reused on another page without copying it.
  • There is no error state: if fetch throws, "Searching…" never goes away.
  • It has a race. Type "mug" quickly, and if the request for "mu" answers after the one for "mug", the older results overwrite the newer ones.

Extract one job and clean up what it starts

The first rule is the simplest. Name composables in camelCase starting with use, and give each one job. The prefix tells the reader that the function holds state and should be called during a component's setup, not in a click handler.

The second rule is about side effects, meaning anything that reaches outside Vue: event listeners, timers, sockets, requests. Start DOM-related ones in onMounted(), which only runs in the browser, so the same code is safe under server-side rendering. Stop every one of them in onUnmounted().

The shortcut is the first job to extract:

src/composables/useKeyShortcut.ts
import { onMounted, onUnmounted, toValue, type MaybeRefOrGetter } from 'vue'

export function useKeyShortcut(key: MaybeRefOrGetter<string>, handler: (event: KeyboardEvent) => void) {
  function onKeydown(event: KeyboardEvent) {
    const target = event.target instanceof Element ? event.target : null
    if (event.key !== toValue(key) || target?.closest('input, textarea, [contenteditable]')) return
    event.preventDefault()
    handler(event)
  }

  onMounted(() => window.addEventListener('keydown', onKeydown))
  onUnmounted(() => window.removeEventListener('keydown', onKeydown))
}

It no longer knows about the search input. It ignores keys typed into any text field, which the original only did for one. And because it reads the key with toValue() at the moment a key is pressed, the caller can pass a plain string or a ref the user can change in settings. That is the next rule.

Accept refs, getters and plain values

A composable that only takes a string forces every caller to unwrap their ref first, and then it never sees the next value. Type the input as MaybeRefOrGetter<T>, and read it with toValue(), which returns the value of a ref, calls a getter (a function such as () => props.query), and passes a plain value through unchanged.

Where the value drives an effect, read it inside the effect so Vue tracks it. Here watch gets a getter that calls toValue(query):

src/composables/useProductSearch.ts
import { onWatcherCleanup, ref, shallowRef, toValue, watch, type MaybeRefOrGetter } from 'vue'

export interface Product { id: number; name: string }

export function useProductSearch(query: MaybeRefOrGetter<string>) {
  const results = shallowRef<Product[]>([])
  const loading = ref(false)
  const error = shallowRef<Error | null>(null)

  watch(() => toValue(query), async (q) => {
    if (!q) {
      results.value = []
      loading.value = false
      error.value = null
      return
    }
    const controller = new AbortController()
    onWatcherCleanup(() => controller.abort())
    loading.value = true
    error.value = null
    try {
      const res = await fetch(`/api/products?q=${encodeURIComponent(q)}`, { signal: controller.signal })
      if (!res.ok) throw new Error(`HTTP ${res.status}`)
      results.value = await res.json()
    } catch (e) {
      if (!controller.signal.aborted) error.value = e as Error
    } finally {
      if (!controller.signal.aborted) loading.value = false
    }
  }, { immediate: true })

  return { results, loading, error }
}

onWatcherCleanup(), added in Vue 3.5, registers a function that runs before the watcher fires again and when it stops. Here it aborts the previous request, which fixes the race: the answer for "mu" is cancelled before it can land. It has to be called before the first await, while Vue still knows which watcher is running.

The signal.aborted guards keep a cancelled request from touching loading or error, because the next run of the watcher sets them. That is why the empty-query branch resets them itself: otherwise clearing the box mid-request would leave "Searching…" on screen.

results is a shallowRef, because the array is always replaced as a whole and Vue does not need to track every product inside it.

For a prop, pass a getter: useProductSearch(() => props.query) keeps tracking the prop, while useProductSearch(props.query) passes the string it holds at that moment and never sees another.

The component is now a few lines of wiring:

src/components/ProductSearch.vue
<script setup lang="ts">
import { ref, useTemplateRef } from 'vue'
import { useKeyShortcut } from '@/composables/useKeyShortcut'
import { useProductSearch } from '@/composables/useProductSearch'

const query = ref('')
const input = useTemplateRef<HTMLInputElement>('input')

const { results, loading, error } = useProductSearch(query)
useKeyShortcut('/', () => input.value?.focus())
</script>

The template stays as it was, plus a line for error.

Return a plain object of refs

The docs recommend returning a plain, non-reactive object whose properties are refs. Callers can then destructure it and keep reactivity, because each ref is still the same ref. Returning reactive() looks tidier and breaks that. Here the composable's last line changes, and a component destructures the result:

src/composables/useProductSearch.tsBreaks
return reactive({ results, loading, error })
// in a component: loading is false forever
const { results, loading } = useProductSearch(query)

Destructuring a reactive object copies out the current values, so loading is just false, cut off from the composable. Return the refs:

src/composables/useProductSearch.tsWorks
return { results, loading, error }

A caller who prefers search.loading in the template can write const search = reactive(useProductSearch(query)) and gets unwrapped refs that stay linked. If you do return something else, say so in a comment above the function.

Call composables synchronously in setup

Call composables in <script setup> or setup(), and call them synchronously. That is when Vue knows the current component, which it needs to attach lifecycle hooks and to stop watchers when the component unmounts.

Call useKeyShortcut from a click handler or a setTimeout and Vue prints a warning that starts [Vue warn]: onMounted is called when there is no active component instance to be associated with. Its listener is never added. A watcher created there, like the one in useProductSearch, is never stopped, and Vue prints no warning about it.

<script setup> has one exception: the compiler restores the component after a top-level await, so a composable called after one still works. The trap is an await inside the composable itself, where no compiler helps:

src/composables/useStock.tsBreaks
import { onUnmounted, ref } from 'vue'

export async function useStock(id: string) {
  const stock = ref<number>(await (await fetch(`/api/stock/${id}`)).json())
  const timer = setInterval(() => { /* poll */ }, 5000)
  onUnmounted(() => clearInterval(timer)) // too late: no active instance, timer leaks
  return { stock }
}

Keep the composable synchronous: create its refs, start the async work without awaiting it, register its hooks, and return refs that fill in when the work finishes:

src/composables/useStock.tsWorks
import { onUnmounted, ref, toValue, type MaybeRefOrGetter } from 'vue'

export function useStock(id: MaybeRefOrGetter<string>) {
  const stock = ref<number | null>(null)
  async function refresh() {
    stock.value = await (await fetch(`/api/stock/${toValue(id)}`)).json()
  }
  refresh()
  const timer = setInterval(refresh, 5000)
  onUnmounted(() => clearInterval(timer))
  return { stock, refresh }
}

refresh() returns at its first await, so the onUnmounted() call still happens during setup. With server-side rendering, start the timer in onMounted() instead: unmount hooks never run on the server, so the interval would never be cleared.

In your components, call composables at the top of <script setup> and put any await after them.

Share state only on purpose

A ref declared outside the function, at the top of the module, is created once. Every component that calls the composable then shares it. Sometimes that is the point, but it is easy to do by accident. Under server-side rendering it is worse: that one ref is shared by every request the server handles, so one user's data can appear on another user's page.

When sharing is intended, say so. For app state such as a cart, use a Pinia store. For a composable that should run once while several components use it, such as one WebSocket connection, wrap it with createSharedComposable from VueUse:

src/composables/useNotifications.ts
import { onScopeDispose, shallowRef } from 'vue'
import { createSharedComposable } from '@vueuse/core'

function useNotificationSocket() {
  const messages = shallowRef<string[]>([])
  const socket = new WebSocket('wss://example.com/notifications')
  socket.addEventListener('message', (event) => {
    messages.value = [...messages.value, String(event.data)]
  })
  onScopeDispose(() => socket.close())
  return { messages }
}

export const useNotifications = createSharedComposable(useNotificationSocket)

The first call runs the function inside its own effect scope (a group of effects that can be stopped together); later calls get the same refs. When the last component using it unmounts, the scope stops and onScopeDispose closes the socket. onUnmounted would be wrong here, because it would tie the socket to whichever component called first. Two details from the VueUse 14.3 source: only the first caller's arguments are used, and on the server it falls back to a fresh, unshared call.

Test a composable with or without a host component

The Vue docs split composables into two kinds. One that only uses reactivity APIs, like useProductSearch, can be called straight from a test. Stub fetch with vi.stubGlobal, call the composable with a plain string, and wait for results with vi.waitFor.

One that uses lifecycle hooks or inject, like useKeyShortcut, needs a component around it. A small helper mounts a component whose only job is to call it:

src/test-utils.ts
import { createApp, type App } from 'vue'

export function withSetup<T>(composable: () => T): [T, App] {
  let result!: T
  const app = createApp({
    setup() {
      result = composable()
      return () => {}
    },
  })
  app.mount(document.createElement('div'))
  return [result, app]
}

The test for useKeyShortcut mounts it with that helper and presses / twice, once before unmount() and once after:

src/composables/useKeyShortcut.spec.ts
import { expect, it, vi } from 'vitest'
import { withSetup } from '../test-utils'
import { useKeyShortcut } from './useKeyShortcut'

it('calls the handler until the component unmounts', () => {
  const handler = vi.fn()
  const [, app] = withSetup(() => useKeyShortcut('/', handler))

  window.dispatchEvent(new KeyboardEvent('keydown', { key: '/' }))
  app.unmount()
  window.dispatchEvent(new KeyboardEvent('keydown', { key: '/' }))

  expect(handler).toHaveBeenCalledTimes(1)
})

The second event proves the cleanup rule: after unmount(), the listener is gone. Run it with Vitest's jsdom or happy-dom environment so window exists.

Check it stuck3 questions from the Composables quiz.
Practice now
Was this helpful?

Keep reading