Scoped Slots

A scoped slot hands data from the component out to the markup the parent supplied, which is what makes renderless components possible.

Syntax<slot name="item" :item="row" /> taken as <template #item="{ item }">...</template> (longhand: v-slot:item="slotProps")

Slot content is compiled in the parent. That is why it can read the parent's refs and cannot read anything declared inside the child. Most of the time that is what you want. It becomes a problem the moment the markup belongs to the parent but the data belongs to the child, such as one row of a loop the child is running. A scoped slot is the way through: the child passes values out to the markup it was given.

How the data travels

On the outlet, bind attributes: <slot name="item" :item="row" :index="i" />. Everything bound there arrives in the parent as a single object, called slot props. The parent takes it with v-slot:item="slotProps", or the shorthand #item="slotProps", and usually destructures on the spot: #item="{ item, index }". The name attribute is not part of the object. v-bind="row" on the outlet spreads every property of row as its own slot prop.

Renderless components

A component can render no markup of its own and expose only state and functions through its default slot. The parent writes all the HTML and calls the functions it was handed. defineSlots, available from Vue 3.3, declares the types of those slot props so the editor can check the destructuring.

Example

Example · vue
<!-- SortedList.vue -->
<script setup lang="ts" generic="T extends { id: number }">
import { computed } from 'vue'

const props = defineProps<{
  items: T[]
  sortKey: keyof T
}>()

defineSlots<{
  item(slotProps: { item: T; index: number }): unknown
}>()

const sorted = computed(() =>
  [...props.items].sort((a, b) =>
    String(a[props.sortKey]).localeCompare(String(b[props.sortKey]))
  )
)
</script>

<template>
  <ul class="sorted-list">
    <li v-for="(entry, index) in sorted" :key="entry.id">
      <slot name="item" :item="entry" :index="index" />
    </li>
  </ul>
</template>

<style scoped>
.sorted-list {
  list-style: none;
  padding: 0;
}
</style>

When to use it

  • A list component that owns paging and sorting while each page renders its own row markup.
  • A table cell that needs the row object to decide between a badge and plain text.
  • A component that owns only behaviour, such as a toggle or a drag target, and lets the caller draw it.

More examples

The parent decides what a row looks like

Both lists show Ada first, then Grace, numbered from one, because the child does the sorting and the parent only draws a row. The first block spells out v-slot:item and reads slotProps.item; the second uses #item and destructures. They are the same thing. The list is keyed on entry.id rather than the loop index, which matters here precisely because sorting reorders the rows.

Example · vue
<!-- PeoplePanel.vue -->
<script setup lang="ts">
import { ref } from 'vue'
import SortedList from './SortedList.vue'

interface Person {
  id: number
  name: string
  role: string
}

const people = ref<Person[]>([
  { id: 2, name: 'Grace', role: 'Admiral' },
  { id: 1, name: 'Ada', role: 'Engineer' }
])
</script>

<template>
  <!-- longhand, no destructuring -->
  <sorted-list :items="people" :sort-key="'name'">
    <template v-slot:item="slotProps">
      {{ slotProps.index + 1 }}. {{ slotProps.item.name }}
    </template>
  </sorted-list>

  <!-- shorthand with destructuring: the same slot, written shorter -->
  <sorted-list :items="people" :sort-key="'name'">
    <template #item="{ item, index }">
      {{ index + 1 }}. {{ item.name }} ({{ item.role }})
    </template>
  </sorted-list>
</template>

A renderless component: all state, no markup

This component draws nothing. Written as <toggle-state v-slot="{ on, toggle }"> the parent gets the boolean and the function and renders whatever it likes: a switch on one screen, a disclosure arrow on another. Note that on.value is written through .value in script, while the template reads it unwrapped. The initial prop only seeds the ref; changing it later will not reset the state, which is usually what you want for a toggle.

Example · vue
<!-- ToggleState.vue -->
<script setup lang="ts">
import { ref } from 'vue'

const props = defineProps<{
  initial?: boolean
}>()

defineSlots<{
  default(slotProps: { on: boolean; toggle: () => void }): unknown
}>()

const on = ref(props.initial ?? false)

function toggle(): void {
  on.value = !on.value
}
</script>

<template>
  <slot :on="on" :toggle="toggle" />
</template>

Spreading an object as slot props

v-bind="tag" is shorthand for binding each property separately: :id="tag.id" :label="tag.label" :count="tag.count". The parent then destructures them directly with #tag="{ label, count }" instead of digging through a wrapper object. Reach for the spread when the slot props are just the row, and bind them one by one when you want to add something the row does not contain, such as an index or a selected flag.

Example · vue
<!-- TagCloud.vue -->
<script setup lang="ts">
interface Tag {
  id: number
  label: string
  count: number
}

const props = defineProps<{
  tags: Tag[]
}>()

defineSlots<{
  tag(slotProps: Tag): unknown
}>()
</script>

<template>
  <div class="cloud">
    <span v-for="tag in props.tags" :key="tag.id">
      <slot name="tag" v-bind="tag" />
    </span>
  </div>
</template>

<style scoped>
.cloud {
  display: flex;
  flex-wrap: wrap;
  gap: 0.5rem;
}
</style>

Discussion

  • Be the first to comment on this lesson.