Composables and reusable logic
Write, name and share composables, decide when state should be per-component or shared, and test one without mounting anything.
Writing a composable
// composables/useLocalStorage.js
import { ref, watch } from 'vue'
export function useLocalStorage(key, fallback) {
const raw = localStorage.getItem(key)
const value = ref(raw === null ? fallback : JSON.parse(raw))
watch(value, next => {
localStorage.setItem(key, JSON.stringify(next))
}, { deep: true })
function reset() { value.value = fallback }
return { value, reset } // refs out, never raw values
}import { useLocalStorage } from '@/composables/useLocalStorage'
const { value: draft, reset } = useLocalStorage('draft', { title: '', body: '' })- A composable is a function whose name starts with
useand which may use reactivity and lifecycle hooks. The name is the only signal to other developers that it must be called during setup. - Call it synchronously in setup, or at the top level of another composable. Calling one from an event handler or after an
awaitloses the component scope, so any hook inside it never registers. - Return an object of refs rather than a reactive object: the caller can destructure, rename and pass them on without breaking reactivity.
- Accept a ref or a getter for anything that can change over time, so the composable can watch it. Accepting a plain value freezes it at call time.
- Keep the side effect inside the composable — the fetch, the listener, the timer — together with its cleanup, so a caller cannot forget it.
Sharing state between composables
// composables/useCart.js — module-scope state, one instance for the whole app
import { ref, computed } from 'vue'
const items = ref([])
const total = computed(() => items.value.reduce((sum, i) => sum + i.price * i.qty, 0))
export function useCart() {
function add(item) {
const existing = items.value.find(i => i.id === item.id)
existing ? (existing.qty += 1) : items.value.push({ ...item, qty: 1 })
}
return { items, total, add }
}// composables/useCheckout.js — composes two others instead of inheriting
import { ref } from 'vue'
import { useCart } from './useCart'
import { useUser } from './useUser'
export function useCheckout() {
const cart = useCart()
const user = useUser()
const step = ref(1)
return { cart, user, step }
}- State declared inside the function is created per call, so each component that uses it gets its own copy. State declared at module scope is shared by every caller, which is what a session-wide value needs.
- Module-scope state is a singleton and therefore unsafe for server rendering: it would leak between requests. On the server the state must be created per request, through a store or an injected value.
- Composables can call other composables. That composition is the replacement for mixin chains:
useCheckoutbuilds onuseCartwithout touching its internals. - Do not swallow errors inside a composable. Return the error ref or throw, so the caller can decide what the user sees.
- If two composables need the same shared value, extract it into a third one and import it in both rather than duplicating the module-scope ref.
💡
A composable has no template and no DOM of its own. The moment you want to return markup, you want a component — or a slot, if the caller should own the markup.
Testing and the Options API comparison
// the same feature in the Options API
export default {
data() { return { n: 0 } },
computed: { double() { return this.n * 2 } },
mounted() { this.timer = setInterval(() => this.n++, 1000) },
unmounted() { clearInterval(this.timer) }
}
// used twice in one component it collides: one data key, one mounted hook// tests/useLocalStorage.spec.js
import { effectScope } from 'vue'
import { useLocalStorage } from '@/composables/useLocalStorage'
it('persists on change', () => {
const scope = effectScope()
let result
scope.run(() => { result = useLocalStorage('k', 0) })
result.value.value = 5
expect(JSON.parse(localStorage.getItem('k'))).toBe(5)
scope.stop() // runs the scope's disposers
})- The Options API scatters one feature across data, computed, methods and hooks. A composable keeps it in one file and can be reused several times in the same component.
- Options API components still work, and converting an existing codebase is optional. Both styles can coexist: a composable is just called from the
setup()option. - A composable that only uses refs and computed can be tested inside an
effectScope, with no component and no DOM. - A composable that registers hooks (
onMounted,onUnmounted) needs a component scope, so test it by mounting a throwaway component that calls it. - Test through the public return value — what the caller receives — not through the internals, so a rewrite does not invalidate the test.
FAQ
Where should composables live?
One file per concern under
src/composables/, named for what it returns: useCart, useDebouncedRef, useMediaQuery. A composable used by exactly one view can live beside it, but move it out the moment a second caller appears.Composable or a Pinia store?
Use a composable for logic and state tied to one component or subtree, and for pure functions that need no global identity. Use a store when the state is app-wide, must be inspected in devtools, or must be created per request for server rendering.
Related
Pinia in depth and data fetching patterns Lifecycle hooks, watchers and cleanup
Last refreshed 2026-09-18.