Skip to content
advanced Phase 6 · Slots, Dynamic Components, and Provide/Inject

Scoped Slots for Render Controls

Let the child pass data back to the parent's slot markup for flexible layouts.

45m
0 problems
Topic Progress 0%

Slots That Take Arguments: Handing Child Data to Parent Markup

Basic slots flow one way: parent markup into the child's outlet, compiled in the parent's scope. Scoped slots add the reverse channel — the child passes bindings to the parent's slot content as function arguments:

<!-- child: UserRow.vue -->
<script setup>
const props = defineProps<{ user: { name: string; role: string } }>()
const isAdmin = computed(() => props.user.role === 'admin')
</script>
<template>
  <tr>
    <td><slot :user="props.user" :is-admin="isAdmin">{{ user.name }}</slot></td>
  </tr>
</template>
<!-- parent -->
<UserRow v-for="u in users" :key="u.id" :user="u">
  <template #default="{ user, isAdmin }">
    {{ user.name }}
    <span v-if="isAdmin" class="badge">admin</span>
  </template>
</UserRow>

The mechanism is exactly the slot-function model from basic slots, viewed from the other end: when the child's <slot :user="..." :is-admin="..." /> executes the parent's slot function, those props become the function's arguments, and the parent's template receives them through v-slot destructuring. Scope stays parent-side — the projected markup still reads the parent's state and imports — but it now also receives the child's data as parameters. Two scopes, one template: parent state by closure, child bindings by argument.

The syntax surface, all one feature:

<!-- full form -->
<template #default="{ user, isAdmin }"> ... </template>

<!-- shorthand for the default slot -->
<v-slot="{ user }"> ... </v-slot>        <!-- or #="{ user }" on a template -->

<!-- named + scoped -->
<template #item="{ item, index }"> ... </template>

<!-- nesting: rename on destructuring, parent data still in scope -->
<template #item="{ item: row, index: i }">
  <Cell :model="models[row.modelId]" :position="i + 1" />
</template>

Destructuring also delivers the fallback-content escape hatch: a parent that binds a scoped slot but supplies no content leaves the child's fallback rendering — so a wrapper can offer a default presentation while every consumer opts into customization. And because arguments are ordinary JavaScript, the child may pass anything — values, computed flags, callbacks, and even the writable v-model pattern's emit function — turning the slot into a full request/response channel:

<slot :row="row" :remove="(id: number) => emit('remove', id)" />

From the child's side, an outlet is just a function it may call — or not — which explains the last two behaviors worth knowing. First, slot props are evaluated when the outlet renders, not when they are bound: the expressions on <slot :user="..." :is-admin="isAdmin" /> execute each time the child renders that outlet, so an expensive computation inline there costs you on every render. Compute once in setup, as isAdmin does, and bind the result. Second, the child can gate rendering on whether content exists at all:

<div class="row">
  <slot v-if="$slots.default" :user="props.user" />
  <span v-else>{{ user.name }}</span>   <!-- child-side default presentation -->
</div>

$slots exposes the incoming slot functions, and checking it in the template — at render time, on every render — is the reliable pattern; the one trap involving watchers and $slots gets its own treatment in Default Slots and Content Projection. Together these complete the mental model: the child invokes the parent's function, with the child's data, at the child's own render moment. Every flexibility in this topic — conditional outlets, control-function arguments, per-instance row rendering — follows from those two facts.

Typing the contract is a defineSlots declaration in the child, which gives the parent's template autocompletion and type checking on the destructured props:

defineSlots<{
  default(props: { user: User; isAdmin: boolean }): any
}>()

The classic failure mode to memorize: a parent destructures { user } from an outlet that was never changed to pass :user — the binding is simply undefined, with no warning, because slot props are function arguments and JavaScript does not check them. When a scoped slot renders undefined fields, compare the child's <slot> bindings against the parent's destructure name for name; that diff is the whole debug. With the channel open, the pattern it was really designed for follows: components that iterate while the parent decides what each item looks like.

The DataTable Pattern: The Child Iterates, the Parent Renders Each Row

A data-table component knows pagination, sorting, column widths, sticky headers, and row selection. The consuming screen knows what a row means — a status pill for orders, a thumbnail for people, a red figure for negative amounts. Put row rendering in the table and you ship a hundred props per column; put iteration in every consumer and you re-implement pagination a dozen times. Scoped slots split the responsibilities exactly along that line.

The child owns the iteration and the per-row context:

<!-- DataTable.vue -->
<script setup generic="T">
const props = defineProps<{ rows: T[]; sort: string }>()
const emit = defineEmits<{ sortChange: [key: string] }>()
</script>
<template>
  <table>
    <thead><tr><th @click="emit('sortChange', 'id')">ID</th></tr></thead>
    <tbody>
      <tr v-for="(row, index) in rows" :key="index" class="row">
        <slot :row="row as T" :index="index" :expanded="index === 0" />
      </tr>
    </tbody>
  </table>
</template>

The parent owns presentation, with full access to its own world:

<DataTable :rows="orders" :sort="sort" @sortChange="sort = $event">
  <template #default="{ row, index }">
    <td>{{ row.id }}</td>
    <td><StatusPill :status="row.status" /></td>
    <td :class="{ negative: row.delta < 0 }">
      {{ currency(row.delta, settings.currency) }}
    </td>
  </template>
</DataTable>

The inversion is the point. The table controls the loop — when rows render, which keys, what wraps each one, selection state, virtualization — while the parent's template controls the content, using parent helpers (currency), parent components (StatusPill), and parent state (settings) that the table has never heard of. This is what the docs mean by scoped slots being built for flexible layout: the child owns iteration and lifecycle; the parent owns presentation. Every major component ecosystem — Vuetify's item slot, Element Plus's #default table slots, TanStack-style headless grids — is this pattern generalized.

Two implementation details that decide whether a table like this scales:

The generic component keeps row types flowing. generic="T" (Vue 3.3+) makes rows: T[] and the slot's :row as T carry the actual row type into the parent's destructure, so row.status is checked against your Order type instead of any. Combined with defineSlots, both halves of the row contract are typed — the parent's IDE autocompletes the destructured bindings.

Cell callbacks beat parent lookups for interaction. Passing :remove='(id) => emit("remove", id)' from the slot — rather than having the parent reach into table internals — keeps the table's API event-based even through the slot channel. The parent's template stays declarative; the table stays in control of its own state machinery.

One discipline makes the pattern hold up under real interaction: key rows by identity, not position. The examples above use :key="index" for brevity; in a sortable, selectable table that choice attaches row state to positions instead of records:

<!-- child: correct — state follows the row through sorts and filters -->
<tr v-for="row in rows" :key="row.id">
  <slot :row="row" />
</tr>

With index keys, sorting reuses each DOM node for whatever row moved into that position: checkboxes stay checked on the wrong orders, an open <input> keeps focus in row 3 while a different record now lives there, and virtualized lists flicker on every filter change. Because the child owns the loop, the child must also own the key — which is another reason the slot contract passes :row wholesale rather than destructured scalars: the parent's markup renders inside the row whose identity the child supplied. Rows without a natural id need a derived composite key (${row.date}-${row.sku}), never a silent fallback to the index.

Variants worth recognizing: menus that pass { open, close } so the parent's trigger toggles child state; date pickers passing { date, isSelected, select } so the parent draws each day cell; wizards passing { step, next, prev }. All are the same shape — a component that owns state, projecting control functions and context into markup it never sees. The last chapter covers when this pattern is the right architecture and when it has been taken too far.

Inverted Control, Judiciously: Headless Components and the Overreach Line

Scoped slots are a control-flow primitive, not a slot convention — which makes them the foundation of headless component architecture and equally capable of producing code nobody can read. The judgment call is knowing which you're building.

The headless pattern strips a component to state and behavior, projecting all presentation:

<!-- useDisclosure-style headless component -->
<Disclosure v-slot="{ isOpen, toggle }">
  <button @click="toggle">{{ isOpen ? 'Hide' : 'Show' }}</button>
  <Collapse :open="isOpen">
    <p>Anything the parent wants — the component owns only the open/closed state machine.</p>
  </Collapse>
</Disclosure>

The component owns the hard part — the state machine, ARIA attribute computation, focus management, animation timing — and the slot hands the parent the values and callbacks to render it however the design system demands. This is how accessible component libraries stay style-agnostic, and it is the same inversion as the data table: state inside, markup outside, the slot as the seam.

When scoped slots overreach, the symptoms are consistent. The first is a slot with six destructured bindings that only ever get used in one combination — the component is really specifying a rendering protocol, and a named sub-component (<RowStatus>) would document it better than an argument list. The second is double iteration: a parent v-fors rows it obtained from the child purely to re-serialize them, so both layers claim the loop. The third is parent templates that reimplement child internals — if the slot props expose rawState, patchFn, and internalRef, the encapsulation you gained by extracting the component is gone; the consumer is now coupled to its internals with none of the compile-time protection. The line to hold: slot props should be the component's public domain concepts (row, isSelected, select) — never its machinery.

A channel-selection question ties the mechanism back one topic. When the goal is sharing values across deep layers — theme, current user, permissions — the answer from provide and inject Across Layers is provide/inject: no per-instance markup involved, every descendant reading one reactive source, the provider owning the state. When the goal is letting a specific parent decide how this specific component renders its items, the answer is a scoped slot. Using the wrong one produces recognizable smells: providing a theme through a scoped slot forces every consumer to write template plumbing for what is pure context; injecting per-row context instead of slotting it buries rendering control inside the child and recreates the hundred-props table. The dividing line to state in an interview: data that is context for rendering anywhere → provide/inject or props; data that shapes one parent's markup → scoped slots.

Mechanics recap for the interview whiteboard, since this topic is the capstone of the composition phase:

Mechanism Direction Scope of content Typical use
props parent → child value n/a data the child renders
events child → parent intent n/a change requests, notifications
default/named slots parent markup → child outlet parent chrome, layout regions
scoped slots child context → parent markup parent + child args per-item rendering, headless seams
provide/inject ancestor → any descendant n/a cross-cutting app values

Read the table as one system: ordinary data on the left of the boundary, ordinary markup on the right, and scoped slots as the one mechanism where the child's domain data meets the parent's presentation code in a single template. Used with that clarity — public concepts in, markup out — they are the most flexible tool Vue ships. Used as a data-backdoor for internals, they are the most expensive. With composition fully covered, the roadmap turns to the temporal dimension: what components do as they come alive and go away, in lifecycle hooks.

Quiz

1. A child renders <slot :row="row" :index="i" /> inside its v-for, and the parent writes <template #default="{ row, index }">. Where does the parent's template get row and index from?

Question 1 options

2. In the DataTable pattern, which responsibility belongs where?

Question 2 options

3. A parent destructures { user } from a scoped slot and user is silently undefined. What is the most likely cause, and the fix?

Question 3 options

4. A headless Disclosure component's slot exposes { isOpen, toggle, rawState, patchFn }. What is the review objection?

Question 4 options

Flashcards

Question

How do scoped slots pass child data to parent markup?

Answer

The child's <slot :x="val" /> passes props as arguments to the parent's slot function; the parent destructures them via v-slot (#default="{ x }" or named #item="{ item }"), and the template remains parent-compiled — parent state by closure, child data by argument. Everything the child can pass (values, flags, callbacks) rides the same channel.

Question

State the DataTable pattern's division of labor.

Answer

The child owns iteration — v-for over rows, keys, wrapping structure, selection/virtualization — and projects per-row context (row, index, flags, callbacks) through the scoped slot. The parent owns each row's presentation using its own components, helpers, and state. Child owns the loop; parent owns the markup.

Question

What is a headless component, and what role do scoped slots play?

Answer

A component reduced to state and behavior — state machine, ARIA computation, focus/timing — projecting all presentation and control functions through a scoped slot ({ isOpen, toggle }; { date, isSelected, select }). The slot is the seam: domain state inside, design-system markup outside — how style-agnostic accessible libraries are built.

Question

Three overreach symptoms of scoped slots, and the line to hold.

Answer

1) Slots with many bindings used in only one combination (prefer a named sub-component). 2) Double iteration — both layers claiming the loop. 3) Exposed internals (rawState, patchFn) coupling consumers to machinery. Line: slot props carry the component's PUBLIC domain concepts (row, isSelected, select) — never its implementation.

Question

How do you type scoped slot props, and why?

Answer

defineSlots<{ default(props: { user: User; isAdmin: boolean }): any }>() in the child types the slot contract so the PARENT's template gets checked destructuring and autocomplete (pair with a generic="T" component for row types). Without it, a binding/destructure name mismatch is a silent undefined — slot props are unchecked function arguments.

Revision Notes

Key Takeaways

  • 1. Scoped slots pass the child's bindings as arguments to the parent-compiled slot function; v-slot destructuring receives them
  • 2. The data-table pattern inverts control cleanly: the child iterates and provides context, the parent renders each row from its own scope
  • 3. Headless components are this inversion productized — behavior inside, all markup projected through the slot seam
  • 4. Slot props are unchecked function arguments: name mismatches are silent undefined, so type the contract with defineSlots and generic row types
  • 5. Hold the line at public domain concepts — exposing internals through slots trades visible props for invisible coupling

Interview Tips

  • • "How do scoped slots differ from normal slots?" — same slot-function mechanism, but the outlet takes props that become the parent template's destructured arguments; content stays parent-compiled while child data arrives as function parameters
  • • "Design a table component that fits any design system" — scoped-slot row rendering: child owns iteration/keys/selection, parent owns cells with its own components; mention generic="T" and defineSlots for typed row contracts
  • • "What are headless components?" — state-and-behavior components projecting presentation through scoped slots; give Disclosure { isOpen, toggle } or date-picker { date, isSelected, select } as the shape
  • • "When are scoped slots the wrong tool?" — when props/events would do, when the slot exposes internals (rawState/patchFn), or when both layers iterate; slot props are public API, not a data backdoor

Cheat Sheet

Scoped Slots

Child:

<slot :row="row" :index="index" :remove="(id) => emit('remove', id)" />

defineSlots<{ default(props: { row: Row; index: number; remove: (id: number) => void }): any }>()
Parent:

<Comp v-for="r in rows" :key="r.id" :data="rows">
  <template #default="{ row, index, remove }">
    <Cell :model="models[row.id]" @delete="remove(row.id)" />
  </template>
</Comp>

Mechanics: outlet props = arguments to the parent's slot function · v-slot destructures them · content still PARENT-compiled (parent state via closure, child data via arguments) · missing binding = silent undefined (no warning) — diff names, add defineSlots.

DataTable pattern: child = loop, keys, structure, selection · parent = per-row presentation with its own components/helpers/state · generic="T" carries row types into the parent's destructure.

Headless components: state machine + ARIA + focus inside; presentation and control functions projected ({ isOpen, toggle }).

Overreach signals: many-binds-one-combo · double iteration · internals exposed (rawState/patchFn). Rule: slot props = public domain concepts, never machinery.

Mechanism map: props = data down · events = intent up · slots = parent markup in · scoped slots = child context back into parent markup · provide/inject = cross-cutting values.

Continue Learning

Next Topics

Related Reading