The Composition API is easy to start with and easy to misuse at scale. In a codebase with two hundred components and forty composables, the questions change: which inputs should a composable accept, who stops its watchers, how does a deeply nested component get a typed service without prop drilling, what exactly does defineProps compile to, and how do you type a generic table component so that its slot props know the row type. This post answers those questions with the reactivity model from Post 00 as the foundation. It covers composable design (refs and getters as inputs, toValue, cleanup, shared state), typed provide/inject, every <script setup> macro including reactive props destructure and defineModel, generic components, useTemplateRef, and async components with Suspense and lazy hydration. Examples target Vue 3.5; compiled output shown in this post was produced by @vue/compiler-sfc 3.5.

Series map: 00 Reactivity Internals → 01 Composition API at Scale (this post) → 02 Compiler, Virtual DOM & Vapor Mode → 03 Ecosystem & Production (Pinia, Router, Nuxt). Future: see the extensions table in 03.


Learning Outcome

  • Write composables that accept refs, getters or plain values through MaybeRefOrGetter and toValue(), and explain why the read must happen inside an effect.
  • Decide what a composable returns (refs, readonly refs, functions) so that callers can destructure it safely.
  • Attach cleanup with onScopeDispose and build a shared, reference-counted composable with a detached effectScope.
  • Type provide/inject with InjectionKey, expose read-only state plus mutation functions, and fail loudly on a missing provider.
  • Explain what each <script setup> macro compiles to: defineProps (with destructure), defineEmits, defineModel, defineSlots, defineExpose, defineOptions.
  • Build a generic component with the generic attribute and typed slots.
  • Use useTemplateRef for element and component refs.
  • Configure defineAsyncComponent, Suspense and the 3.5 lazy hydration strategies, and know which parts are still experimental.

Chapter 1 — What a composable is

Terminology

  • Composable — a function that uses Composition API features to encapsulate stateful logic. By convention named useX. A pure helper (formatPrice) is not a composable.
  • Setup context — the synchronous window in which Vue knows the "current instance": setup() or <script setup>. Lifecycle hooks, inject() and automatic effect collection depend on it.
  • MaybeRef<T> — T | Ref<T> | ShallowRef<T> | WritableComputedRef<T>.
  • MaybeRefOrGetter<T> — MaybeRef<T> | ComputedRef<T> | (() => T). The type for "any reactive or plain input".
  • toValue(x) (3.3+) — normalises a MaybeRefOrGetter: calls a getter, unwraps a ref, returns anything else as-is.
  • InjectionKey<T> — a typed Symbol used as a provide/inject key.
  • Compiler macro — a function like defineProps that exists only at compile time inside <script setup>. It needs no import and cannot be called at run time.

The contract

A composable called inside setup gets three things for free:

  1. Every computed, watch and watchEffect it creates is collected by the component's effect scope and stopped on unmount (Post 00, Chapter 7).
  2. Lifecycle hooks it registers (onMounted, onUnmounted) attach to the calling component.
  3. inject() resolves against the calling component's ancestors.

All three depend on the current instance, which Vue sets while setup runs synchronously. That gives the first rule: call composables synchronously at the top of setup, not in event handlers, setTimeout callbacks or after an arbitrary await.

<script setup> softens the await part. The compiler wraps each top-level await so the instance is restored afterwards. This source:

1
2
3
4
5
<script setup lang="ts">
const user = await fetchUser()
const route = useRoute()
</script>

compiles (3.5) to:

1
2
3
4
5
6
7
8
9
10
11
12
async setup(__props) {
let __temp: any, __restore: any
const user = (
([__temp, __restore] = _withAsyncContext(() => fetchUser())),
__temp = await __temp,
__restore(),
__temp
)
const route = useRoute()
// …
}

__restore() puts the instance back, so useRoute() after the await still works. A plain async setup() in an export default {} component, or an async function you wrote yourself, gets no such help. The top-level await also turns the component into an async dependency that needs a <Suspense> ancestor (Chapter 8).

Return refs, not a reactive object

1
2
3
4
5
6
7
8
9
10
// Caller-friendly: destructuring keeps reactivity
export function useCounter() {
const count = ref(0)
const double = computed(() => count.value * 2)
const inc = () => count.value++
return { count: readonly(count), double, inc }
}

const { count, inc } = useCounter() // count is still a ref

Returning reactive({ count, double }) would make every caller that destructures lose reactivity (Post 00, Chapter 8). If a caller wants the object form, it can wrap the result itself: const counter = reactive(useCounter()) unwraps the refs.

Trick: return readonly(ref) for state the composable owns and a function for each allowed mutation. Callers cannot bypass your invariants, and a code search for inc( finds every writer.


Chapter 2 — Inputs: refs, getters and plain values

Accept MaybeRefOrGetter, read with toValue() inside an effect

A composable that takes a string snapshot freezes at its first value. One that accepts only Ref<string> forces callers to wrap props and computed values. Accept all three:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import { shallowRef, toValue, watchEffect, onWatcherCleanup, readonly, type MaybeRefOrGetter } from 'vue'

export function useJson<T>(url: MaybeRefOrGetter<string | null>) {
const data = shallowRef<T>()
const error = shallowRef<Error>()
const pending = shallowRef(false)

watchEffect(async () => {
const u = toValue(url) // read INSIDE the effect, before any await: tracked
if (!u) return
const ctrl = new AbortController()
onWatcherCleanup(() => ctrl.abort())
pending.value = true
error.value = undefined
try {
const res = await fetch(u, { signal: ctrl.signal })
if (!res.ok) throw new Error(`HTTP ${res.status}`)
data.value = (await res.json()) as T
} catch (e) {
if ((e as Error).name !== 'AbortError') error.value = e as Error
} finally {
if (!ctrl.signal.aborted) pending.value = false
}
})

return { data: readonly(data), error: readonly(error), pending: readonly(pending) }
}

Callers can now pass anything:

1
2
3
4
5
const props = defineProps<{ id: number }>()
useJson(`/api/users/1`) // plain value: fetched once
useJson(urlRef) // ref
useJson(() => `/api/users/${props.id}`) // getter over a prop: refetches when id changes

Three details in that composable are deliberate:

  • toValue(url) runs inside watchEffect and before the first await, so the dependency is tracked (Post 00, Chapter 5).
  • onWatcherCleanup aborts the previous request when url changes and when the component unmounts, so a slow old response can never overwrite a newer one.
  • shallowRef for data: API responses are replaced, not mutated, and a deep proxy over a large JSON tree is wasted work. Nuxt 4 made the same choice for useFetch (Post 03).

toRef with a getter

When a composable needs a real ref (for example to hand to a library that expects Ref<T>), toRef(() => props.id) (3.3+) creates a read-only ref backed by a getter, with no computed overhead:

1
2
const id = toRef(() => props.id)   // Readonly<Ref<number>>

Options objects

For more than two parameters, take an options object and make only the fields that may change at run time MaybeRefOrGetter (interval?: MaybeRefOrGetter<number>), keeping set-once flags plain (immediate?: boolean).

Common pitfall: usePoll({ interval: props.ms }) passes a snapshot of the prop. Pass { interval: () => props.ms }.


Chapter 3 — Cleanup, ownership and shared state

onScopeDispose over onUnmounted

1
2
3
4
5
6
7
8
9
export function useEventListener<K extends keyof WindowEventMap>(
type: K, handler: (e: WindowEventMap[K]) => void, opts?: AddEventListenerOptions
) {
window.addEventListener(type, handler, opts)
const stop = () => window.removeEventListener(type, handler, opts)
if (getCurrentScope()) onScopeDispose(stop)
return stop
}

onScopeDispose runs when the component unmounts and when a standalone effectScope stops, so the composable also works in a store, a test, or another composable's scope. The getCurrentScope() guard avoids a development warning when the function is called outside any scope; returning stop gives such callers a way to clean up by hand.

Watch out: window does not exist during SSR. A composable that touches browser APIs at call time crashes the server render. Do browser work in onMounted (which never runs on the server) or guard with typeof window !== 'undefined'.

A shared composable with reference counting

Some state should exist once for all components that use it: one resize listener, one WebSocket, one media-query watcher. A detached scope owns it, and reference counting disposes it when the last user unmounts:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import { effectScope, onScopeDispose, getCurrentScope, type EffectScope } from 'vue'

export function createSharedComposable<T>(factory: () => T): () => T {
let users = 0
let state: T | undefined
let scope: EffectScope | undefined

const release = () => {
if (--users <= 0 && scope) {
scope.stop()
scope = state = undefined
users = 0
}
}

return () => {
users++
if (!scope) {
scope = effectScope(true) // detached: not collected by the first caller's scope
state = scope.run(factory)
}
if (getCurrentScope()) onScopeDispose(release)
return state as T
}
}

export const useWindowWidth = createSharedComposable(() => {
const width = shallowRef(window.innerWidth)
useEventListener('resize', () => (width.value = window.innerWidth), { passive: true })
return readonly(width)
})

Without effectScope(true), the listener and any watchers would be collected by the first component that called useWindowWidth, and would stop when that component unmounted even though others still use them. VueUse ships a production version of this helper under the same name.

Global state: module scope vs provide

A ref at module scope is fine in a client-only SPA, but in SSR it is shared by every request on the server (Post 00, Chapter 8). Per-user state goes through app.provide, Pinia or Nuxt's useState; module scope holds only constants and truly global caches.


Chapter 4 — Typed provide / inject

InjectionKey<T>

1
2
3
4
5
6
7
8
9
10
11
// cart-context.ts
import type { InjectionKey, Ref } from 'vue'

export interface CartContext {
items: Readonly<Ref<readonly CartItem[]>>
add(item: CartItem): void
remove(id: string): void
}

export const CartKey: InjectionKey<CartContext> = Symbol('cart')
1
2
3
4
5
6
7
8
// CartProvider.vue <script setup>
const items = ref<CartItem[]>([])
provide(CartKey, {
items: readonly(items),
add: (item) => items.value.push(item),
remove: (id) => { items.value = items.value.filter((i) => i.id !== id) }
})

inject(CartKey) is now typed as CartContext | undefined. The provider exposes read-only state and named mutations, which keeps writes in one place, the same shape as the composable return in Chapter 1.

Fail loudly on a missing provider

undefined checks at every call site are noise. Wrap the pair once:

1
2
3
4
5
6
export function useCart(): CartContext {
const ctx = inject(CartKey)
if (!ctx) throw new Error('useCart() needs a <CartProvider> ancestor')
return ctx
}

Defaults and factories

inject(ThemeKey, 'light') gives a plain default. inject(LoggerKey, () => new ConsoleLogger(), true) treats the default as a factory (third argument), so the expensive object is built only when no provider exists; without true, a function default is returned as-is, not called.

Where injection works

  • Inside setup / <script setup> of a descendant, including after a <script setup> top-level await (Chapter 1).
  • app.provide(key, value) makes a value available to every component in the app, and to app.runWithContext(fn) (3.3+), which router guards and plugins use to call inject() outside components.
  • hasInjectionContext() (3.3+) returns whether inject() can be called right now without a warning. Libraries use it to support both component and non-component callers; useRoute() in Vue Router is the example the Vue type definitions give.

Watch out: a component can inject from ancestors only. A sibling or a component in another subtree cannot see the value, and Teleport does not change this: injection follows the component tree, not the DOM.


Chapter 5 — The <script setup> macros

defineProps and reactive destructure (3.5)

Since 3.5, destructuring defineProps() is stable and enabled by default, with native default values:

1
2
3
4
5
6
7
<script setup lang="ts">
import { computed, watch } from 'vue'
const { count = 0, label } = defineProps<{ count?: number; label: string }>()
const double = computed(() => count * 2)
watch(() => count, (v) => console.log(v))
</script>

The compiler rewrites every use of a destructured name into a property read on the props proxy. The relevant part of the 3.5 output:

1
2
3
4
5
6
7
8
9
props: {
count: { type: Number, required: false, default: 0 },
label: { type: String, required: true }
},
setup(__props: any) {
const double = computed(() => __props.count * 2)
watch(() => __props.count, (v) => console.log(v))
}

So count is not a local variable at run time; it is __props.count, read inside the computed getter and tracked. Two consequences:

  • Passing the name itself to watch(count, …) would pass a number. The compiler rejects it: "count" is a destructured prop and should not be passed directly to watch(). Pass a getter () => count instead.
  • Passing it to a composable passes a snapshot. Use useThing(() => count); with MaybeRefOrGetter inputs (Chapter 2) this is free.

withDefaults(defineProps<…>(), { … }) still works and remains the way to declare defaults without destructuring.

What the script setup macros compile to in Vue 3.5: destructured props become __props reads with runtime prop options, defineEmits becomes the emits option, and defineModel becomes a model prop, a modifiers prop, an update event and a useModel call.

defineEmits

The 3.3+ named-tuple syntax is the most readable typed form:

1
2
3
4
5
6
const emit = defineEmits<{
select: [item: Row]
'update:page': [page: number]
}>()
emit('select', row)

It compiles to emits: ["select", "update:page"]. Declaring emits is not only for types: an event listener for a declared event is removed from $attrs, so it does not fall through to the root element as a native listener.

defineModel (stable since 3.4)

defineModel declares a prop and its update event in one line and returns a ref that reads the prop and emits on write:

1
2
3
const model = defineModel<string>({ required: true })          // v-model
const [title, titleModifiers] = defineModel<string, 'trim'>('title') // v-model:title with modifiers

The 3.5 compiler turns this into a modelValue prop, a modelModifiers prop, a title prop, a titleModifiers prop, the events update:modelValue and update:title, and two useModel(__props, …) calls. Modifiers can be applied in a set transformer:

1
2
3
4
5
6
const [value, modifiers] = defineModel<string, 'uppercase'>({
set(v) {
return modifiers.uppercase ? v.toUpperCase() : v
}
})

Watch out: a default on defineModel is used when the parent does not bind v-model. The child then works as local state while the parent's own value (if it has one but did not bind it) stays different. The Vue docs warn about this desynchronisation. Give a default only to models that are genuinely optional.

defineSlots, defineExpose, defineOptions

1
2
3
4
5
6
7
8
defineSlots<{
default(props: { item: Row; index: number }): any
empty(): any
}>()

defineExpose({ focus, reset }) // what a parent's template ref can see
defineOptions({ inheritAttrs: false }) // component options without a second <script>

defineSlots (3.3+) is type-only: it types useSlots() and the slot props that parents receive. defineExpose matters because <script setup> components are closed by default: a parent holding a template ref to the child sees only what was exposed. defineOptions (3.3+) is spread into the component definition; in the output it appears as ...{ inheritAttrs: false }.

Macro Since Compiles to
defineProps 3.0 (destructure stable 3.5) props option; destructured names → __props.x
defineEmits 3.0 (tuple syntax 3.3) emits option; returns __emit
defineModel 3.4 model prop + modifiers prop + update: event + useModel()
defineSlots 3.3 Types only
defineExpose 3.0 __expose({ … }) in setup
defineOptions 3.3 Options spread into the component

Chapter 6 — Generic components and TypeScript

The generic attribute (3.3+)

A table, a select or a virtual list should know the row type from its items prop. The generic attribute on <script setup> declares type parameters, with constraints and defaults written as in TypeScript:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<script setup lang="ts" generic="T extends { id: string | number }">
const props = defineProps<{ items: T[]; selected?: T }>()
const emit = defineEmits<{ select: [item: T] }>()
defineSlots<{ default(props: { item: T; index: number }): any }>()
</script>


<template>
<ul>
<li v-for="(item, index) in props.items" :key="item.id" @click="emit('select', item)">
<slot :item="item" :index="index" />
</li>
</ul>
</template>
1
2
3
4
5
<!-- Parent: T is inferred as User -->
<ItemList :items="users" @select="(u) => open(u.email)">
<template #default="{ item }">{{ item.name }}</template>
</ItemList>

At run time there are no types: the compiled props option is { items: { type: Array, required: true }, selected: { type: null, required: false } }. All the checking happens in vue-tsc and the editor (Vue - Official extension), which is why CI should run vue-tsc --noEmit; the Vite build does not type-check.

Typing refs to generic or exposed components

InstanceType<typeof Comp> does not work for generic components, because their default export is a function type. The vue-component-type-helpers package (from the Vue language tools) provides ComponentExposed<typeof Comp> for exactly this.

Trick: type-based defineProps generates runtime options only from types it can resolve; a union of object shapes becomes type: Object or null, so runtime validation is weaker than the type. Keep prop interfaces local or in a .ts file imported by relative path — the SFC compiler resolves imported types itself (3.3+), but complex conditional or mapped types from third-party packages can fail to resolve and stop the build.


Chapter 7 — Template refs with useTemplateRef (3.5)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<script setup lang="ts">
import
{ useTemplateRef, onMounted } from 'vue'
import SearchBox from './SearchBox.vue'

const input = useTemplateRef<HTMLInputElement>('query')
const box = useTemplateRef<InstanceType<typeof SearchBox>>('box')
onMounted(() => input.value?.focus())
</script>


<template>
<input ref="query" />
<SearchBox ref="box" />
</template>

Why it replaced the old pattern of a ref(null) whose variable name matched the ref attribute:

  • The ref name is a string, so it can be dynamic (:ref="row-${id}") and can come from a composable that does not own the template.
  • A composable can accept the key: useElementSize(useTemplateRef('panel')).
  • The returned ref is read-only, which prevents accidental writes.

Timing: template refs are set in the post-flush phase (Post 00, Chapter 6). They are null before mount and during a pre watcher that runs before the first render; read them in onMounted or a flush: 'post' watcher. A ref on a v-for element holds an array, and the docs note that its order is not guaranteed to match the source array.

A ref on a component gives the instance's exposed surface only — whatever defineExpose listed.


Chapter 8 — Async components, Suspense and lazy hydration

defineAsyncComponent

1
2
3
4
5
6
7
8
9
10
11
const Chart = defineAsyncComponent({
loader: () => import('./Chart.vue'),
loadingComponent: ChartSkeleton,
delay: 200, // default: show the loading component only after 200 ms
errorComponent: ChartError,
timeout: 10_000, // default: no timeout
onError(error, retry, fail, attempts) {
attempts <= 3 ? retry() : fail()
}
})

The loader becomes a separate chunk. The 200 ms delay exists so fast loads do not flash a skeleton. If the async component is inside a <Suspense>, it is controlled by that boundary by default (suspensible: true) and its own loading, error, delay and timeout options are ignored.

Suspense (still experimental)

<Suspense> waits for async dependencies in its subtree — components with an async setup() (including any <script setup> with a top-level await) and suspensible async components — and shows its #fallback slot until all resolve:

1
2
3
4
5
<Suspense timeout="300" @resolve="onReady">
<Dashboard /> <!-- has top-level await in <script setup> -->
<template #fallback><Spinner /></template>
</Suspense>
  • On the first render, the fallback shows until everything resolves. On later changes, Suspense keeps showing the previous content until the new branch resolves, and only switches to the fallback after timeout ms.
  • Events: pending, resolve, fallback.
  • Errors in async setup are not handled by Suspense. Catch them with onErrorCaptured in a parent or with the app-level errorHandler.
  • Nested <Suspense> boundaries need the suspensible prop (3.3+) on the inner one to join the parent's resolution.

The Vue docs still mark <Suspense> as experimental, with an API that may change. Nuxt relies on it for every page, which is a practical sign that it is dependable, but in a plain Vue app keep its use to route-level boundaries.

Lazy hydration (3.5)

In an SSR app, every component normally hydrates as soon as the client bundle runs. 3.5 lets an async component delay its hydration:

1
2
3
4
5
6
7
import { defineAsyncComponent, hydrateOnVisible, hydrateOnIdle, hydrateOnInteraction } from 'vue'

const Comments = defineAsyncComponent({
loader: () => import('./Comments.vue'),
hydrate: hydrateOnVisible() // or hydrateOnIdle(), hydrateOnInteraction('click'),
}) // hydrateOnMediaQuery('(max-width: 600px)')

The server still renders the full HTML, so the content is visible and indexable. Only the JavaScript work of attaching listeners and effects is deferred. Nuxt exposes the same strategies as hydrate-on-visible and similar props on Lazy* components (Post 03).


Practice Exercises

Exercise 1 — Fix the composable signature

function useTitle(title: string) sets document.title once. Rewrite it so that useTitle('Home'), useTitle(titleRef) and useTitle(() => ${n.value} new) all work and stay in sync.

Solution
1
2
3
4
export function useTitle(title: MaybeRefOrGetter<string>) {
watchEffect(() => { document.title = toValue(title) })
}

toValue reads inside the effect, so a ref or getter is tracked; a plain string is simply read once. In SSR, move the body into onMounted or use Unhead / Nuxt's useHead.

Exercise 2 — Who stops the watcher?

A composable is called from a Pinia setup store instead of a component. Its watch never stops, and its onUnmounted logs a warning. Explain both, and fix the composable.

Solution

There is no component instance, so onUnmounted has nothing to attach to (warning). The store's setup runs inside Pinia's own effect scope, so the watch is collected by that scope and lives as long as the store — usually the app. Replace onUnmounted with onScopeDispose, which attaches to whichever scope is active: the component, the store, or a test's effectScope.

Exercise 3 — Typed context

Write an InjectionKey and provide/use helpers for a Toaster service with show(message: string, tone?: 'info' | 'error'), such that a missing provider throws with a clear message.

Solution
1
2
3
4
5
6
7
8
9
export interface Toaster { show(message: string, tone?: 'info' | 'error'): void }
export const ToasterKey: InjectionKey<Toaster> = Symbol('toaster')
export const provideToaster = (t: Toaster) => provide(ToasterKey, t)
export function useToaster(): Toaster {
const t = inject(ToasterKey)
if (!t) throw new Error('useToaster() needs provideToaster() in an ancestor')
return t
}

Exercise 4 — Predict the compiler

What does the 3.5 compiler do with each line, and which one fails to compile?

1
2
3
4
5
const { size = 'md', items } = defineProps<{ size?: 'sm' | 'md'; items: string[] }>()
const n = computed(() => items.length)
watch(items, () => {})
useSort(() => items)
Solution

Line 1 becomes a props option with size: { type: String, required: false, default: 'md' } and items: { type: Array, required: true }. Line 2 becomes computed(() => __props.items.length) (tracked). Line 3 fails: a destructured prop passed directly to watch() is a compile error; use watch(() => items, …). Line 4 passes a getter that reads __props.items and is fine.

Exercise 5 — A generic select

Sketch a <BaseSelect> with generic="T" that takes options: T[], a label: (o: T) => string, and v-model of type T | undefined.

Solution
1
2
3
4
5
6
7
8
9
10
11
<script setup lang="ts" generic="T">
const model = defineModel<T | undefined>()
defineProps<{ options: T[]; label: (o: T) => string }>()
</script>

<template>
<ul role="listbox">
<li v-for="(o, i) in options" :key="i" role="option"
:aria-selected="o === model" @click="model = o">
{{ label(o) }}</li>
</ul>
</template>

Using the index as key is acceptable only because the option list is static; with a dynamic list, add a getKey: (o: T) => PropertyKey prop.


Cheat-sheet

Need Use
Any reactive or plain input MaybeRefOrGetter<T> + toValue() inside an effect
Cleanup that works everywhere onScopeDispose(fn)
One instance for all users createSharedComposable with effectScope(true)
Typed DI InjectionKey<T> + provide / inject wrapped in useX() that throws
Inject outside components app.runWithContext(fn) (3.3+)
Props with defaults const { a = 1 } = defineProps<…>() (3.5)
Watch a destructured prop watch(() => a, cb)
Two-way binding defineModel<T>() (3.4+), [m, mods] = defineModel(…) for modifiers
Typed slots defineSlots<{ default(p: …): any }>()
Generic component <script setup lang="ts" generic="T extends …">
Template ref useTemplateRef<El>('name') (3.5)
Code-split component defineAsyncComponent({ loader, delay, timeout, onError })
Defer hydration hydrate: hydrateOnVisible() etc. (3.5)
Type-check in CI vue-tsc --noEmit

What's next

Post 02 goes one layer down: what the SFC compiler produces from a template. It covers the compile pipeline, patch flags (the numbers you saw in this post's compiled output), static caching, the block tree, the keyed diff with its longest-increasing-subsequence step, v-once and v-memo, and Vapor mode, which compiles the same components to direct DOM operations. For the same questions in the React world — hooks as composables, context as provide/inject, generic components — see the React Deep Dive series.


References