Slots or Props

Data goes in props, markup goes in slots: the question that decides which, and what each choice costs you later.

Syntaxprop = value the child renders; slot = markup the parent renders; scoped slot = child's data, parent's markup

Props and slots both pass something from parent to child. A prop passes a value. A slot passes markup. The question that decides between them is who should own the rendering.

The test

If the component knows how the thing should look and only needs the data, use a prop: heading="Billing", :count="7". If the parent has to decide the markup and the component only decides where it goes, use a slot. If the parent decides the markup but the data it needs lives inside the component, that is a scoped slot.

Signals you chose wrong

Props that are really markup in disguise are the common case: icon, buttonLabel, showRetry, retryLabel accumulating one per caller, or a prop holding an HTML string rendered with v-html. One slot replaces the pile, and v-html on parent-supplied strings is an injection risk the slot does not have.

The other direction is rarer: a slot for a single short string. Props are typed and validated with defineProps, while defineSlots (Vue 3.3 and later) types slots for the editor only, with nothing enforced at runtime.

Both at once

Default to props and add a slot whose fallback content renders the prop. Callers who want the normal rendering pass nothing.

Example

Example · vue
<!-- EmptyState.vue -->
<script setup lang="ts">
defineProps<{
  title: string
  description?: string
}>()

defineSlots<{
  action?(): unknown
}>()
</script>

<template>
  <div class="empty">
    <h3>{{ title }}</h3>
    <p v-if="description">{{ description }}</p>

    <div v-if="$slots.action" class="empty-action">
      <slot name="action" />
    </div>
  </div>
</template>

<style scoped>
.empty {
  padding: 2rem;
  text-align: center;
  color: #555;
}
.empty-action {
  margin-top: 1rem;
}
</style>

When to use it

  • Deciding whether a shared button component should take an icon prop or an icon slot.
  • Replacing a growing set of label and visibility props with a single actions slot.
  • Keeping a formatted value as a prop while letting one screen override the formatting.
  • Reviewing a component whose props list has outgrown its purpose.

More examples

Props piling up, and the slot that replaces them

Both calls render the same bar. The first grew four props and two events to describe two buttons, and the next caller who wants a link instead of a button needs a fifth prop. The second keeps message and icon as props, because the bar renders those itself, and moves the buttons into an actions slot. The page already has the handlers, so the events disappear with them.

Example · vue
<!-- CheckoutNotice.vue -->
<script setup lang="ts">
import NoticeBar from './NoticeBar.vue'

function retryPayment(): void {
  // start the payment again
}

function hideNotice(): void {
  // mark the notice as dismissed
}
</script>

<template>
  <!-- before: every variation needs another prop and another event -->
  <notice-bar
    message="Payment failed"
    icon="alert"
    :show-retry="true"
    retry-label="Try again"
    :show-dismiss="true"
    dismiss-label="Not now"
    @retry="retryPayment"
    @dismiss="hideNotice"
  />

  <!-- after: the text stays a prop, the buttons become markup this page owns -->
  <notice-bar message="Payment failed" icon="alert">
    <template #actions>
      <button type="button" @click="retryPayment">Try again</button>
      <button type="button" @click="hideNotice">Not now</button>
    </template>
  </notice-bar>
</template>

A prop with a slot as the escape hatch

Used as <price-cell :cents="1299" currency="GBP" /> the cell renders the formatted amount and the caller passes no slot at all. The one screen that needs to strike through a discounted price fills the default slot and gets both the formatted string and the raw cents to work with. This is the compromise worth reaching for: props for the common path, a scoped slot with fallback for the exception, no second component.

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

const props = defineProps<{
  cents: number
  currency: string
}>()

defineSlots<{
  default?(slotProps: { formatted: string; cents: number }): unknown
}>()

const formatted = computed(() =>
  new Intl.NumberFormat('en-GB', {
    style: 'currency',
    currency: props.currency
  }).format(props.cents / 100)
)
</script>

<template>
  <span class="price">
    <slot :formatted="formatted" :cents="cents">{{ formatted }}</slot>
  </span>
</template>

<style scoped>
.price {
  font-variant-numeric: tabular-nums;
}
</style>

When a prop is the right answer

No slot belongs here. The bar's whole job is to turn two numbers into a width, and nobody wants to supply that markup. Adding a slot would only invite callers to render something the component then cannot clamp, animate or label. When you can state the rendering rule in one sentence, the rule belongs in the component and the inputs belong in props.

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

const props = defineProps<{
  value: number
  max?: number
  label: string
}>()

const percent = computed(() => {
  const max = props.max ?? 100
  return Math.min(100, Math.max(0, (props.value / max) * 100))
})
</script>

<template>
  <div class="bar" role="progressbar" :aria-label="label">
    <div class="fill" :style="{ width: `${percent}%` }" />
  </div>
</template>

<style scoped>
.bar {
  height: 0.5rem;
  background: #eee;
  border-radius: 999px;
  overflow: hidden;
}
.fill {
  height: 100%;
  background: #2b6cb0;
}
</style>

Discussion

  • Be the first to comment on this lesson.