Composables are how a Vue codebase shares logic, so "how do you share logic between components" and "what makes a good composable" are standard senior questions. The follow-ups are where the depth shows: what breaks when a composable is called after an await, why module-level state is a bug in SSR, and how you test one.
useX, called synchronously in setup (or from another composable), and returns a plain object of refs and functions so callers can destructure it. Pure helpers without reactivity are utils, not composables.MaybeRefOrGetter<T> and normalise with toValue(). That lets the caller pass useTitle(title), useTitle(() => props.title) or useTitle('Home') and lets the composable react to changes in the first two.onMounted, inject, getCurrentInstance need the current component instance, which exists only during synchronous setup. Inside <script setup>, await is compiled to restore the instance afterwards; inside a plain async function it is not. So: call composables at the top, before any await, and do async work inside them, not around them.onScopeDispose (works in components and in effectScope()), not only onUnmounted. VueUse's useEventListener, useIntervalFn and useResizeObserver clean up for you, which is why they are safer than raw addEventListener.window/document at call time (guard with import.meta.client, or do it in onMounted); no setInterval on the server (it never unmounts). In Nuxt, cross-component shared state goes in useState, which is per request and serialised into the payload.return { data, error, status, refresh } destructures safely; return reactive({...}) loses reactivity when destructured. Wrap what callers must not write in readonly().createSharedComposable or createGlobalState from VueUse, and in Nuxt those must be backed by useState to be SSR-safe.app/composables/ (and app/utils/). Nested folders need an index re-export or imports.dirs. shared/utils is auto-imported in both app and server code and must not import Vue or Nitro APIs.A composable with every convention applied: flexible input, options, status tracking, cancellation, scoped cleanup:
export interface UseAsyncTaskOptions {
immediate?: boolean
}
export function useAsyncTask<T, Args extends unknown[]>(
task: (signal: AbortSignal, ...args: Args) => Promise<T>,
{ immediate = false }: UseAsyncTaskOptions = {},
) {
const data = shallowRef<T>()
const error = shallowRef<unknown>()
const status = ref<'idle' | 'pending' | 'success' | 'error'>('idle')
let controller: AbortController | undefined
async function run(...args: Args) {
controller?.abort()
controller = new AbortController()
status.value = 'pending'
try {
data.value = await task(controller.signal, ...args)
status.value = 'success'
}
catch (e) {
if (controller.signal.aborted) return // superseded, not an error
error.value = e
status.value = 'error'
}
}
// Works inside a component and inside a bare effectScope(), unlike onUnmounted.
onScopeDispose(() => controller?.abort())
if (immediate) run(...([] as unknown as Args))
return { data: readonly(data), error: readonly(error), status: readonly(status), run }
}
A media query composable that is SSR-safe and accepts a ref, a getter or a string:
export function useMedia(query: MaybeRefOrGetter<string>, serverFallback = false) {
const matches = ref(serverFallback)
if (import.meta.server) return readonly(matches)
let mql: MediaQueryList | undefined
const update = () => { matches.value = mql?.matches ?? false }
watchEffect((onCleanup) => {
mql = window.matchMedia(toValue(query))
update()
mql.addEventListener('change', update)
onCleanup(() => mql?.removeEventListener('change', update))
})
return readonly(matches)
}
Testing a composable without a component:
import { effectScope } from 'vue'
import { describe, expect, it } from 'vitest'
import { useAsyncTask } from '~/composables/useAsyncTask'
describe('useAsyncTask', () => {
it('ignores a superseded call', async () => {
const scope = effectScope()
const task = scope.run(() => useAsyncTask(async (signal, ms: number) => {
await new Promise((r, reject) => {
const t = setTimeout(r, ms)
signal.addEventListener('abort', () => { clearTimeout(t); reject(new Error('aborted')) })
})
return ms
}))!
const slow = task.run(50)
const fast = task.run(10)
await Promise.all([slow, fast])
expect(task.data.value).toBe(10)
expect(task.status.value).toBe('success')
scope.stop()
})
})
const user = ref(null) at the top of useUser.ts is one ref for the whole Node process. Request A logs in, request B renders A's name. The fix in Nuxt is useState('user', () => null); in plain Vue SSR it is provide/inject from the app root. This is the single most common SSR bug in composables and the memory-leaks section covers the variant where the state is a growing array.
In a plain async function, await fetch(...) then useRoute() fails or returns the wrong instance, because the current instance is only set synchronously. <script setup> restores it after await, and Nuxt's experimental.asyncContext extends the same guarantee to Nuxt composables, but the portable rule is: composables first, awaits after.
useAsyncTask, wire it to a search input with refDebounced, and confirm in the Network panel that superseded requests are cancelled.ref, render it in two browser tabs against nuxt dev, and log in on one. Observe the leak in the other tab's server render, then fix it with useState.effectScope test above and a second test that asserts the abort runs on scope.stop()."It does one thing, is named useX, takes MaybeRefOrGetter inputs normalised with toValue so it works with values, refs and getters, takes an options object with defaults, and returns a plain object of refs and functions, with readonly on anything the caller must not write. It is called synchronously at the top of setup, before any await, and it cleans up after itself with onScopeDispose so it also works in a bare effectScope. And it is SSR-safe: no module-level state, no window at call time, no intervals on the server. In Nuxt, anything shared across components goes through useState, not a module variable."
"A util is stateless and pure; if it needs no refs it is a util and I put it in utils or shared. A composable creates fresh state per call, so it is right for logic tied to one component or one caller. A store is one instance per app, and per request on the server, so it is for state that genuinely belongs to the whole application: the current user, a cart, cross-page filters. The smell I look for is a composable pretending to be a store through module-level state, which is a leak on the server, or a store holding UI state that only one component reads."
Nothing yet. Notes filed here show up on this page and the section overview.
Component design
Props, emits and defineModel, slots and their types, attribute fallthrough, provide/inject, defineExpose and generic components, designed so that a team cannot misuse them.
Rendering and lifecycle
Lifecycle order on server and client, nextTick, Suspense, Teleport, KeepAlive and Transition, async components, template compilation, keys and render functions.