Vue docs, chapter by chapter

Components II — attrs, slots, provide/inject, async

Fallthrough attributes and inheritAttrs, slots in every form, provide/inject with reactivity and Symbol keys, and async components with loading states and lazy hydration strategies.

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.

Know — Fallthrough Attributes

  • Definition: attributes and 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).
  • Nested roots: if the root is itself a component, the attrs fall through again to its root, minus anything that component declares.
  • 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).
  • Multiple roots: no automatic fallthrough; Vue warns unless $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.

Know — Slots

  • <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.
  • Fallback content is what sits between <slot> and </slot>, used when nothing is provided.
  • Named slots: <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].
  • Scoped slots: the child passes props to the outlet (<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.
  • Renderless components expose only logic through scoped slots; the guide notes composables are usually the more efficient alternative.

Know — Provide / inject

  • 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).
  • Reactivity: provide refs or reactive objects to keep the link live; keep mutations in the provider by providing update functions; wrap with readonly() to stop injectors from writing.
  • Symbol keys in a shared module avoid collisions in large apps and are the typing anchor (InjectionKey<T>).
  • hasInjectionContext() tells a composable whether inject can be called.

Know — Async Components

  • defineAsyncComponent(() => import('./Comp.vue')) loads on first render and integrates with bundler code splitting. Register locally or globally like any component.
  • Options form: loader, loadingComponent, delay (default 200 ms before showing the loading component), errorComponent, timeout.
  • Lazy hydration strategies (3.5+): 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.
  • With Suspense: an async component inside <Suspense> is treated as an async dependency; its own loading state is ignored in favour of the Suspense fallback.

How it works

app/components/Docs/Card.vue
<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>
app/components/Docs/theme.ts
export interface Theme { mode: Readonly<Ref<'light' | 'dark'>>, toggle: () => void }
export const THEME: InjectionKey<Theme> = Symbol('theme')
app/components/Docs/ThemeProvider.vue
<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>
async + lazy hydration
const Chart = defineAsyncComponent({
  loader: () => import('./HeavyChart.vue'),
  loadingComponent: Spinner,
  delay: 200,
  errorComponent: LoadFailed,
  timeout: 10_000,
  hydrate: hydrateOnVisible({ rootMargin: '100px' }),
})
Gotcha· A provided plain value never updates

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.

Exercise

Exercise
  • Build a two-root component, pass class and @click, read the warning, then fix it with $attrs on one root.
  • Provide a ref and a readonly version of it; try to write through each in a child and observe the warning.
  • Give an async component a delay of 0 and 200 and throttle the network; see when the spinner appears.

Be able to say

Be able to say· What exactly is in $attrs and when do you need inheritAttrs: false?

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

Your notes

Open in Notes

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