---
title: "Composables: the rules that keep them reusable · vuejs.school"
description: "Six rules that make a composable safe to reuse, shown by refactoring one tangled search component into two small composables, with tests for both."
canonical_url: "https://vuejs.school/community/blog/composables-the-rules-that-keep-them-reusable"
meta:
  "og:description": "Six rules that make a composable safe to reuse, shown by refactoring one tangled search component into two small composables, with tests for both."
  "og:image": "https://vuejs.school/_og/d/a_~Q29tcG9zYWJsZXM6IHRoZSBydWxlcyB0aGF0IGtlZXAgdGhlbSByZXVzYWJsZSDCtyB2dWVqcy5zY2hvb2w%2Cc_OgCard.takumi%2Ctitle_~Q29tcG9zYWJsZXM6IHRoZSBydWxlcyB0aGF0IGtlZXAgdGhlbSByZXVzYWJsZQ%2Cdescription_~U2l4IHJ1bGVzIHRoYXQgbWFrZSBhIGNvbXBvc2FibGUgc2FmZSB0byByZXVzZSwgc2hvd24gYnkgcmVmYWN0b3Jpbmcgb25lIHRhbmdsZWQgc2VhcmNoIGNvbXBvbmVudCBpbnRvIHR3byBzbWFsbCBjb21wb3NhYmxlcywgd2l0aCB0ZXN0cyBmb3IgYm90aC4%2Ceyebrow_~RGVlcCBkaXZlIMK3IDYgbWluIHJlYWQ%2Ctint_lilac%2Cp_Ii9jb21tdW5pdHkvYmxvZy9jb21wb3NhYmxlcy10aGUtcnVsZXMtdGhhdC1rZWVwLXRoZW0tcmV1c2FibGUi%2Cs_fef8b8tQQRRl0asO.png"
  "og:image:alt": "Composables: the rules that keep them reusable · vuejs.school"
  "og:image:height": "600"
  "og:image:type": image/png
  "og:image:width": "1200"
  "og:title": "Composables: the rules that keep them reusable · vuejs.school"
  "twitter:image": "https://vuejs.school/_og/d/a_~Q29tcG9zYWJsZXM6IHRoZSBydWxlcyB0aGF0IGtlZXAgdGhlbSByZXVzYWJsZSDCtyB2dWVqcy5zY2hvb2w%2Cc_OgCard.takumi%2Ctitle_~Q29tcG9zYWJsZXM6IHRoZSBydWxlcyB0aGF0IGtlZXAgdGhlbSByZXVzYWJsZQ%2Cdescription_~U2l4IHJ1bGVzIHRoYXQgbWFrZSBhIGNvbXBvc2FibGUgc2FmZSB0byByZXVzZSwgc2hvd24gYnkgcmVmYWN0b3Jpbmcgb25lIHRhbmdsZWQgc2VhcmNoIGNvbXBvbmVudCBpbnRvIHR3byBzbWFsbCBjb21wb3NhYmxlcywgd2l0aCB0ZXN0cyBmb3IgYm90aC4%2Ceyebrow_~RGVlcCBkaXZlIMK3IDYgbWluIHJlYWQ%2Ctint_lilac%2Cp_Ii9jb21tdW5pdHkvYmxvZy9jb21wb3NhYmxlcy10aGUtcnVsZXMtdGhhdC1rZWVwLXRoZW0tcmV1c2FibGUi%2Cs_fef8b8tQQRRl0asO.png"
  "twitter:image:alt": "Composables: the rules that keep them reusable · vuejs.school"
---

> Index of every page on this site: [llms.txt](https://vuejs.school/llms.txt)

## 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.ts**Breaks***

```
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.ts**Works***

```
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.ts**Breaks***

```
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.ts**Works***

```
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](https://vuejs.org/guide/scaling-up/ssr.html#component-lifecycle-hooks), 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 stuck**3 questions from the Composables quiz.

[**Practice now**](https://vuejs.school/learn/advanced/composables?tab=quiz)

**Was this helpful?**

## **Keep reading**

[Release notes · 7 min read**Vue 3.6 and Vapor Mode: where things stand**](https://vuejs.school/community/blog/vue-3-6-and-vapor-mode-where-things-stand) [Deep dive · 7 min read**Vapor Mode, explained without the hype**](https://vuejs.school/community/blog/vapor-mode-explained-without-the-hype)

```json
{"@context":"https://schema.org","@graph":[{"@id":"https://vuejs.school/#website","@type":"WebSite","description":"A free AI-instructed school for Vue.js. Short tutorials, runnable code and quizzes that adapt to you.","name":"vuejs.school","url":"https://vuejs.school/","publisher":{"@id":"https://vuejs.school/#identity"}},{"@id":"https://vuejs.school/community/blog/composables-the-rules-that-keep-them-reusable#webpage","@type":"WebPage","dateModified":"2026-10-27T19:31:40.253Z","datePublished":"2026-06-18","description":"Six rules that make a composable safe to reuse, shown by refactoring one tangled search component into two small composables, with tests for both.","name":"Composables: the rules that keep them reusable","url":"https://vuejs.school/community/blog/composables-the-rules-that-keep-them-reusable","breadcrumb":{"@id":"https://vuejs.school/community/blog/composables-the-rules-that-keep-them-reusable#breadcrumb"},"isPartOf":{"@id":"https://vuejs.school/#website"},"potentialAction":[{"@type":"ReadAction","target":["https://vuejs.school/community/blog/composables-the-rules-that-keep-them-reusable"]}],"primaryImageOfPage":{"@id":"https://vuejs.school/#logo"}},{"@id":"https://vuejs.school/#identity","@type":"Organization","description":"A free AI-instructed school for Vue.js. Short tutorials, runnable code and quizzes that adapt to you.","name":"vuejs.school","url":"https://vuejs.school","logo":{"@id":"https://vuejs.school/#logo"}},{"@id":"https://vuejs.school/community/blog/composables-the-rules-that-keep-them-reusable#article","dateModified":"2026-10-27T19:31:40.253Z","datePublished":"2026-06-18","description":"Six rules that make a composable safe to reuse, shown by refactoring one tangled search component into two small composables, with tests for both.","headline":"Composables: the rules that keep them reusable","inLanguage":"en","isAccessibleForFree":true,"thumbnailUrl":"https://vuejs.school/_og/d/a_~Q29tcG9zYWJsZXM6IHRoZSBydWxlcyB0aGF0IGtlZXAgdGhlbSByZXVzYWJsZSDCtyB2dWVqcy5zY2hvb2w%2Cc_OgCard.takumi%2Ctitle_~Q29tcG9zYWJsZXM6IHRoZSBydWxlcyB0aGF0IGtlZXAgdGhlbSByZXVzYWJsZQ%2Cdescription_~U2l4IHJ1bGVzIHRoYXQgbWFrZSBhIGNvbXBvc2FibGUgc2FmZSB0byByZXVzZSwgc2hvd24gYnkgcmVmYWN0b3Jpbmcgb25lIHRhbmdsZWQgc2VhcmNoIGNvbXBvbmVudCBpbnRvIHR3byBzbWFsbCBjb21wb3NhYmxlcywgd2l0aCB0ZXN0cyBmb3IgYm90aC4%2Ceyebrow_~RGVlcCBkaXZlIMK3IDYgbWluIHJlYWQ%2Ctint_lilac%2Cp_Ii9jb21tdW5pdHkvYmxvZy9jb21wb3NhYmxlcy10aGUtcnVsZXMtdGhhdC1rZWVwLXRoZW0tcmV1c2FibGUi%2Cs_fef8b8tQQRRl0asO.png","@type":["Article","BlogPosting"],"articleSection":["Deep dive"],"author":{"@id":"https://vuejs.school/#identity"},"image":{"@id":"https://vuejs.school/#/schema/image/1"},"isPartOf":{"@id":"https://vuejs.school/community/blog/composables-the-rules-that-keep-them-reusable#webpage"},"mainEntityOfPage":{"@id":"https://vuejs.school/community/blog/composables-the-rules-that-keep-them-reusable#webpage"},"publisher":{"@id":"https://vuejs.school/#identity"}},{"@id":"https://vuejs.school/community/blog/composables-the-rules-that-keep-them-reusable#breadcrumb","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","name":"Blog","item":"https://vuejs.school/community/blog","position":1},{"@type":"ListItem","name":"Composables: the rules that keep them reusable","position":2}]},{"@id":"https://vuejs.school/#logo","@type":"ImageObject","caption":"vuejs.school","contentUrl":"https://vuejs.school/apple-touch-icon.png","inLanguage":"en","url":"https://vuejs.school/apple-touch-icon.png"},{"@id":"https://vuejs.school/#/schema/image/1","@type":"ImageObject","contentUrl":"https://vuejs.school/_og/d/a_~Q29tcG9zYWJsZXM6IHRoZSBydWxlcyB0aGF0IGtlZXAgdGhlbSByZXVzYWJsZSDCtyB2dWVqcy5zY2hvb2w%2Cc_OgCard.takumi%2Ctitle_~Q29tcG9zYWJsZXM6IHRoZSBydWxlcyB0aGF0IGtlZXAgdGhlbSByZXVzYWJsZQ%2Cdescription_~U2l4IHJ1bGVzIHRoYXQgbWFrZSBhIGNvbXBvc2FibGUgc2FmZSB0byByZXVzZSwgc2hvd24gYnkgcmVmYWN0b3Jpbmcgb25lIHRhbmdsZWQgc2VhcmNoIGNvbXBvbmVudCBpbnRvIHR3byBzbWFsbCBjb21wb3NhYmxlcywgd2l0aCB0ZXN0cyBmb3IgYm90aC4%2Ceyebrow_~RGVlcCBkaXZlIMK3IDYgbWluIHJlYWQ%2Ctint_lilac%2Cp_Ii9jb21tdW5pdHkvYmxvZy9jb21wb3NhYmxlcy10aGUtcnVsZXMtdGhhdC1rZWVwLXRoZW0tcmV1c2FibGUi%2Cs_fef8b8tQQRRl0asO.png","inLanguage":"en","url":"https://vuejs.school/_og/d/a_~Q29tcG9zYWJsZXM6IHRoZSBydWxlcyB0aGF0IGtlZXAgdGhlbSByZXVzYWJsZSDCtyB2dWVqcy5zY2hvb2w%2Cc_OgCard.takumi%2Ctitle_~Q29tcG9zYWJsZXM6IHRoZSBydWxlcyB0aGF0IGtlZXAgdGhlbSByZXVzYWJsZQ%2Cdescription_~U2l4IHJ1bGVzIHRoYXQgbWFrZSBhIGNvbXBvc2FibGUgc2FmZSB0byByZXVzZSwgc2hvd24gYnkgcmVmYWN0b3Jpbmcgb25lIHRhbmdsZWQgc2VhcmNoIGNvbXBvbmVudCBpbnRvIHR3byBzbWFsbCBjb21wb3NhYmxlcywgd2l0aCB0ZXN0cyBmb3IgYm90aC4%2Ceyebrow_~RGVlcCBkaXZlIMK3IDYgbWluIHJlYWQ%2Ctint_lilac%2Cp_Ii9jb21tdW5pdHkvYmxvZy9jb21wb3NhYmxlcy10aGUtcnVsZXMtdGhhdC1rZWVwLXRoZW0tcmV1c2FibGUi%2Cs_fef8b8tQQRRl0asO.png"}]}
```