All articles

Vue.js - Dynamic and Generic Slots

How dynamic slot names work in Vue.js, and how generic components and the defineSlots macro from Vue 3.3 give those slots precise types in script setup.

Published 8 min read

Vue.js - Dynamic and Generic Slots

Introduction

In this article we look at dynamic and generic slots in Vue.js, using <script setup> and the defineSlots macro introduced in Vue 3.3. Together they let us build a component that other developers can customise freely, while their editor tells them, slot by slot, what they are working with. That makes the component easier to reuse, too.

We will work through three small components: a table that renders one slot per column, a generic list whose slot knows the type of its items, and finally a table that does both.

Dynamic slots

Dynamic slots are scoped slots with dynamic names: the name is worked out while the component renders, instead of being written into the template in advance.

There are two sides to this. A component can render slots under computed names, and a parent can fill a slot whose name it computes.

Here is the first side. BasicTable takes rows and a list of columns, and wraps every cell in a slot named after its column:

BasicTable.vuevue
<script setup lang="ts">
defineProps<{
  rows: Record<string, unknown>[]
  columns: string[]
}>()
</script>

<template>
  <table>
    <thead>
      <tr>
        <th v-for="column in columns" :key="column" scope="col">
          <slot :name="`header-${column}`" :column="column">{{ column }}</slot>
        </th>
      </tr>
    </thead>
    <tbody>
      <tr v-for="(row, index) in rows" :key="index">
        <td v-for="column in columns" :key="column">
          <slot :name="`cell-${column}`" :value="row[column]" :row="row">{{ row[column] }}</slot>
        </td>
      </tr>
    </tbody>
  </table>
</template>

With the columns number, customer, amount and status, the table offers eight slots: header-number, cell-number, header-customer, and so on. Each one has fallback content, so a parent only fills the slots it wants to change.

The second side is the parent. A slot name can be a dynamic argument, written in square brackets after v-slot: or its shorthand #:

vue
<script setup lang="ts">
import { ref } from 'vue'
import BasicTable from './BasicTable.vue'

const columns = ['number', 'customer', 'amount', 'status']
const invoices = [
  { number: 'INV-1042', customer: 'Northwind Traders', amount: 1240, status: 'paid' },
  { number: 'INV-1043', customer: 'Blue Harbor', amount: 860.5, status: 'issued' },
  { number: 'INV-1044', customer: 'Atlas Studio', amount: 2310, status: 'draft' },
]

const highlighted = ref('customer')
</script>

<template>
  <select v-model="highlighted">
    <option v-for="column in columns" :key="column">{{ column }}</option>
  </select>

  <BasicTable :rows="invoices" :columns="columns">
    <template #[`cell-${highlighted}`]="{ value }">
      <mark>{{ value }}</mark>
    </template>
  </BasicTable>
</template>

Output

numbercustomeramountstatus
INV-1042Northwind Traders1240paid
INV-1043Blue Harbor860.5issued
INV-1044Atlas Studio2310draft

Pick another column and the highlight moves: the parent is now filling a different slot.

This works, but only at runtime. As far as TypeScript is concerned, value is unknown, so we cannot format an amount without converting it first. And nothing checks the names: a slot called cell-amout is accepted without complaint and simply never renders.

Generic slots

Vue 3.3 added two things that solve this. The first is the generic attribute on <script setup>, which gives a component type parameters, just like a generic function. The second is the defineSlots macro, which declares the slots a component renders and the props each one receives.

DataList uses both. It does not know what kind of items it will be given, so it calls their type T:

DataList.vuevue
<script setup lang="ts" generic="T">
defineProps<{
  items: T[]
}>()

defineSlots<{
  item(props: { item: T, index: number }): any
  empty?(): any
}>()
</script>

<template>
  <ul v-if="items.length">
    <li v-for="(item, index) in items" :key="index">
      <slot name="item" :item="item" :index="index" />
    </li>
  </ul>
  <slot v-else name="empty" />
</template>

defineSlots takes a type and nothing else. Each key is a slot name, and each value is a function whose first parameter describes the slot props. The return type is ignored for now, so any is fine.

When we use the component, T is inferred from the items prop:

vue
<script setup lang="ts">
import { computed, ref } from 'vue'
import DataList from './DataList.vue'

interface Invoice {
  number: string
  customer: string
  amount: number
  status: 'draft' | 'issued' | 'paid'
}

const invoices: Invoice[] = [/* the same three invoices */]

const query = ref('')
const matches = computed(() =>
  invoices.filter(invoice => invoice.customer.toLowerCase().includes(query.value.toLowerCase())),
)
</script>

<template>
  <input v-model="query" type="search" placeholder="Filter by customer">

  <DataList :items="matches">
    <template #item="{ item, index }">
      {{ index + 1 }}. {{ item.number }} · {{ item.customer }}
    </template>
    <template #empty>
      <p>No invoice matches.</p>
    </template>
  </DataList>
</template>

Output

  • 1. INV-1042 · Northwind Traders
  • 2. INV-1043 · Blue Harbor
  • 3. INV-1044 · Atlas Studio

Inside the #item slot, item is an Invoice. The editor completes its fields, and a typo such as item.custmer is a compile error rather than an empty line on the screen.

One detail is worth knowing. For a simple slot like this one, Vue's tooling would infer the same types from the template even without defineSlots. Declaring them is still useful: the contract is written in one place, and an optional slot such as empty can be marked with ?. With dynamic names, as we are about to see, inference is no longer enough.

Dynamic and generic together

Now let's give the table the same treatment. Making it generic is the easy part:

vue
<script setup lang="ts" generic="T extends object">
defineProps<{
  rows: T[]
  columns: (keyof T & string)[]
}>()
</script>

columns now only accepts keys of the row type, and the template can stay as it was. Vue's tooling even infers the slot names: cell-amount is known, and cell-total is rejected. But every value has the same type, the union of all the columns (string | number for our invoices), because a single <slot> in the template serves every column.

To give each column its own type, we describe the slots with a mapped type:

DataTable.vuevue
<script setup lang="ts" generic="T extends object">
type CellSlots = {
  [K in keyof T & string as `cell-${K}`]?: (props: { value: T[K], row: T }) => any
}

type HeaderSlots = {
  [K in keyof T & string as `header-${K}`]?: (props: { column: K }) => any
}

defineProps<{
  rows: T[]
  columns: (keyof T & string)[]
}>()

defineSlots<CellSlots & HeaderSlots>()

const cellSlot = (column: keyof T & string) => `cell-${column}` as keyof CellSlots
const headerSlot = (column: keyof T & string) => `header-${column}` as keyof HeaderSlots
</script>

<template>
  <table>
    <thead>
      <tr>
        <th v-for="column in columns" :key="column" scope="col">
          <slot :name="headerSlot(column)" :column="column">{{ column }}</slot>
        </th>
      </tr>
    </thead>
    <tbody>
      <tr v-for="(row, index) in rows" :key="index">
        <td v-for="column in columns" :key="column">
          <slot :name="cellSlot(column)" :value="row[column]" :row="row">{{ row[column] }}</slot>
        </td>
      </tr>
    </tbody>
  </table>
</template>

Read the first type as: for every key K of T, there is an optional slot named cell- followed by that key, and its value has the type of that field, T[K]. The as clause is what renames the keys.

The two helper functions are there for a reason. While T is still a type parameter, TypeScript cannot work out that `cell-${column}` is one of the keys of CellSlots, and reports that the string cannot be used to index the slots type. So we assert the name once, in the script, and the template stays clean. This only concerns the inside of the component. Whoever uses it gets fully checked slots:

vue
<script setup lang="ts">
import DataTable from './DataTable.vue'

interface Invoice {
  number: string
  customer: string
  amount: number
  status: 'draft' | 'issued' | 'paid'
}

const invoices: Invoice[] = [/* the same three invoices */]

const money = new Intl.NumberFormat('en', { style: 'currency', currency: 'EUR' })
</script>

<template>
  <DataTable :rows="invoices" :columns="['number', 'customer', 'amount', 'status']">
    <template #cell-amount="{ value }">
      {{ money.format(value) }}
    </template>
    <template #cell-status="{ value }">
      <span class="pill" :data-status="value">{{ value }}</span>
    </template>
  </DataTable>
</template>

Output

numbercustomeramountstatus
INV-1042Northwind Traders€1,240.00paid
INV-1043Blue Harbor€860.50issued
INV-1044Atlas Studio€2,310.00draft

In #cell-amount, value is a number, so it can go straight into Intl.NumberFormat. In #cell-status it is 'draft' | 'issued' | 'paid'. A slot for a column that does not exist is a compile error, and so is a column name that is not a key of Invoice.

Dynamic names are still allowed on the parent side. If the name is computed from a union, such as #[`cell-${key}`] with key typed as 'number' | 'customer', then value gets the union of those two columns' types.

One note on the constraint: we wrote T extends object, not T extends Record<string, unknown>. An interface has no index signature, so with the second form an Invoice[] would be rejected.

Conclusion

Dynamic names let this table expose a slot for each column. In BasicTable, the names are unrestricted strings and cell values are unknown because of the declared row type. Making the table generic lets Vue infer valid names and a union of the column types; the mapped types passed to defineSlots go further by giving each column's slot its own value type.