Vue 3 in depth

Composables

The rules for writing composables that are reusable, typed, testable and SSR-safe, the conventions VueUse established, and how they differ from utils and stores.

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.

Know

  • A composable is a function that uses the Composition API to hold stateful logic. It is named 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.
  • Inputs accept refs, getters or values. Type them as 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.
  • Options object for configuration, with defaults destructured inside. Positional booleans are unreadable at the call site.
  • Lifecycle rules. 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.
  • Cleanup belongs to the scope. Register cleanup with 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.
  • SSR safety. No state at module level (it is shared by every request on the server); no 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 refs, not a reactive object. return { data, error, status, refresh } destructures safely; return reactive({...}) loses reactivity when destructured. Wrap what callers must not write in readonly().
  • Composable versus store versus util. A composable creates new state per call; a Pinia store is one instance per app (per request on the server); a util has no state. Shared state via a composable needs createSharedComposable or createGlobalState from VueUse, and in Nuxt those must be backed by useState to be SSR-safe.
  • Nuxt auto-imports the top level of 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.

How it works

A composable with every convention applied: flexible input, options, status tracking, cancellation, scoped cleanup:

app/composables/useAsyncTask.ts
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:

app/composables/useMedia.ts
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:

test/useAsyncTask.spec.ts
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()
  })
})
Gotcha· Module-level state on the server

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.

Gotcha· Calling a composable after await

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.

Exercise

Exercise
  • Write useAsyncTask, wire it to a search input with refDebounced, and confirm in the Network panel that superseded requests are cancelled.
  • Create a composable with a module-level 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.
  • Write the effectScope test above and a second test that asserts the abort runs on scope.stop().

Be able to say

Be able to say· What makes a good composable?

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

Be able to say· Composable, store or util: how do you choose?

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

Your notes

Open in Notes

Nothing yet. Notes filed here show up on this page and the section overview.