Four chapters that decide whether a wrapper component "just works": where an undeclared class lands, what a slot can see, whether an injected value updates, and what an async component shows while it loads. Each has one or two rules that people get wrong for years.
v-on listeners passed to a component that are not declared props or emits. With a single root, they are added to that root; class and style merge with the root's own, and a v-on listener is added alongside the child's own handler (both fire).inheritAttrs: false (via defineOptions in <script setup>) disables automatic fallthrough; then v-bind="$attrs" places them where you want. $attrs contains everything undeclared, including class, style and listeners as onClick-style keys, with original casing preserved (foo-bar stays foo-bar).$attrs is bound explicitly.useAttrs() returns the attrs object in setup; it is not reactive (it always reflects the latest values but cannot be watched), so read it in a render or use onUpdated for side effects.<slot> is the outlet; content passed between the component's tags fills it. Render scope: slot content is compiled in the parent and sees only the parent's scope.<slot> and </slot>, used when nothing is provided.<slot name="header">; provide with <template v-slot:header> or #header; unnamed content goes to default (implicit <template #default> when other named slots exist). Conditional slots with v-if="$slots.header"; dynamic slot names #[slotName].<slot :item="item" :index="i">); the parent receives them with v-slot="slotProps" or destructuring #default="{ item }". The name slot prop is reserved. Named scoped slots combine both forms; default-slot shorthand on the component tag cannot be mixed with named slots.provide(key, value) in setup (or app.provide at app level, useful for plugins); inject(key, defaultValue) in any descendant; a factory default uses inject(key, () => new X(), true) (the third argument marks the function as a factory).readonly() to stop injectors from writing.InjectionKey<T>).hasInjectionContext() tells a composable whether inject can be called.defineAsyncComponent(() => import('./Comp.vue')) loads on first render and integrates with bundler code splitting. Register locally or globally like any component.loader, loadingComponent, delay (default 200 ms before showing the loading component), errorComponent, timeout.hydrateOnIdle(timeout?), hydrateOnVisible({ rootMargin }), hydrateOnMediaQuery('(max-width: 500px)'), hydrateOnInteraction(['wheel', 'mouseover']), and custom strategies via hydrate: (hydrate, forEachElement) => …. Nuxt exposes these as hydrate-on-* props on Lazy components.<Suspense> is treated as an async dependency; its own loading state is ignored in favour of the Suspense fallback.<script setup lang="ts">
defineOptions({ inheritAttrs: false })
const attrs = useAttrs() // not reactive; fine for read-through at render time
defineSlots<{
default(props: { close: () => void }): any
header(): any
[name: `action-${string}`]: (props: { id: string }) => any
}>()
const open = ref(true)
</script>
<template>
<section v-if="open" class="card" v-bind="attrs"> <!-- class merges with "card" -->
<header v-if="$slots.header"><slot name="header" /></header>
<slot :close="() => (open = false)">Nothing to show</slot>
</section>
</template>
export interface Theme { mode: Readonly<Ref<'light' | 'dark'>>, toggle: () => void }
export const THEME: InjectionKey<Theme> = Symbol('theme')
<script setup lang="ts">
import { THEME } from './theme'
const mode = ref<'light' | 'dark'>('light')
provide(THEME, { mode: readonly(mode), toggle: () => { mode.value = mode.value === 'light' ? 'dark' : 'light' } })
</script>
const Chart = defineAsyncComponent({
loader: () => import('./HeavyChart.vue'),
loadingComponent: Spinner,
delay: 200,
errorComponent: LoadFailed,
timeout: 10_000,
hydrate: hydrateOnVisible({ rootMargin: '100px' }),
})
provide('count', count.value) provides a number; the child injects 0 forever. Provide the ref itself, and if the child must not write, wrap it in readonly(). The same applies to app.provide.
class and @click, read the warning, then fix it with $attrs on one root.delay of 0 and 200 and throttle the network; see when the spinner appears."$attrs holds every attribute and v-on listener the parent passed that the component did not declare as a prop or an emit: class, style, id, data-*, aria-*, and listeners as onClick-style keys, with the original casing preserved. With one root element Vue applies them automatically and merges class and style. I set inheritAttrs: false when the meaningful element is not the root, for example a wrapper around an input, and bind v-bind=\"$attrs\" on that input, and I must do the same when a component has several roots because then Vue cannot choose."
Nothing yet. Notes filed here show up on this page and the section overview.
Components I — registration, props, events, v-model
Component registration, props declaration and validation including Boolean casting and reactive destructure, event declaration and validation, and component v-model with defineModel.
Reusability — composables, directives, plugins
The Composables chapter's conventions and restrictions, custom directive hooks and the binding object, and how plugins install through app.use.