Vue Deep Dive — 01 · Composition API at Scale
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
MaybeRefOrGetterandtoValue(), 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
onScopeDisposeand build a shared, reference-counted composable with a detachedeffectScope. - Type
provide/injectwithInjectionKey, 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
genericattribute and typed slots. - Use
useTemplateReffor element and component refs. - Configure
defineAsyncComponent,Suspenseand 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 aMaybeRefOrGetter: calls a getter, unwraps a ref, returns anything else as-is.InjectionKey<T>— a typedSymbolused as aprovide/injectkey.- Compiler macro — a function like
definePropsthat 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:
- Every
computed,watchandwatchEffectit creates is collected by the component's effect scope and stopped on unmount (Post 00, Chapter 7). - Lifecycle hooks it registers (
onMounted,onUnmounted) attach to the calling component. 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 forinc(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 insidewatchEffectand before the firstawait, so the dependency is tracked (Post 00, Chapter 5).onWatcherCleanupaborts the previous request whenurlchanges and when the component unmounts, so a slow old response can never overwrite a newer one.shallowReffordata: API responses are replaced, not mutated, and a deep proxy over a large JSON tree is wasted work. Nuxt 4 made the same choice foruseFetch(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:
windowdoes not exist during SSR. A composable that touches browser APIs at call time crashes the server render. Do browser work inonMounted(which never runs on the server) or guard withtypeof 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-levelawait(Chapter 1). app.provide(key, value)makes a value available to every component in the app, and toapp.runWithContext(fn)(3.3+), which router guards and plugins use to callinject()outside components.hasInjectionContext()(3.3+) returns whetherinject()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
injectfrom 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); withMaybeRefOrGetterinputs (Chapter 2) this is free.
withDefaults(defineProps<…>(), { … }) still works and remains the way to declare defaults without destructuring.
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
defaultondefineModelis used when the parent does not bindv-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
definePropsgenerates runtime options only from types it can resolve; a union of object shapes becomestype: Objectornull, so runtime validation is weaker than the type. Keep prop interfaces local or in a.tsfile 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
timeoutms. - Events:
pending,resolve,fallback. - Errors in async setup are not handled by Suspense. Catch them with
onErrorCapturedin a parent or with the app-levelerrorHandler. - Nested
<Suspense>boundaries need thesuspensibleprop (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
- Composables — conventions,
toValue, return values. - Provide / Inject and TypeScript with Composition API —
InjectionKey, typing props, emits, refs. <script setup>— every macro,generic, top-levelawait.- Component v-model —
defineModel, modifiers, the default-value caveat. - Composition API helpers —
useTemplateRef,useId,useModel. - Async Components — options and lazy hydration strategies.
- Suspense — experimental status, events, nesting.
- Announcing Vue 3.5 — props destructure,
useTemplateRef, lazy hydration. - VueUse — production versions of
createSharedComposable,useEventListenerand many more.










