SupportWriter space ↗
← Back to the journal
Vue.js

Vue components: props, emits, v-model, and slots

Learn how Vue components communicate through props and events, build a custom v-model with defineModel, and compose flexible layouts with slots.

DAdministrator · 01 Oct 2026 · 4 min read

Why components matter

A Vue application is a tree of components. An article page, for example, might consist of SiteHeader, ArticleList, ArticleCard, and LikeButton. Breaking the UI into small components makes code easier to read, test, and reuse.

For components to work together, Vue provides a clear communication pattern: props down, events up. A parent passes data through props, and a child notifies its parent through events.

Props: data from parent to child

Props are declared with the defineProps macro. It is available automatically in <script setup> without an import.

<!-- ArticleCard.vue -->
<script setup>
const props = defineProps({
  title: { type: String, required: true },
  summary: { type: String, default: '' },
  readingMinutes: { type: Number, default: 1 },
  tags: { type: Array, default: () => [] },
})
</script>

<template>
  <article class="card">
    <h3>{{ title }}</h3>
    <p>{{ summary }}</p>
    <small>{{ props.readingMinutes }} min read · {{ tags.join(', ') }}</small>
  </article>
</template>

Default values for objects and arrays must be written as functions so that every instance gets its own copy.

With TypeScript, props can be declared using types:

const props = withDefaults(defineProps<{
  title: string
  summary?: string
  readingMinutes?: number
}>(), {
  summary: '',
  readingMinutes: 1,
})

The parent passes props like HTML attributes. Use : for values that aren't strings:

<ArticleCard
  v-for="article in articles"
  :key="article.id"
  :title="article.title"
  :summary="article.summary"
  :reading-minutes="article.readingMinutes"
/>

Note that readingMinutes in JavaScript is written as reading-minutes in the template. Vue accepts both forms.

One-way data flow

Props are read-only for the child component. Never write props.title = '...'. If a child needs an initial value from a prop and then manages it itself, copy it into local state:

const localCount = ref(props.initialCount)

If you need a derived value, use computed:

const shortTitle = computed(() => props.title.slice(0, 60))

Emits: notifying the parent

When something happens inside a child, such as a button click, the child emits an event declared with defineEmits.

<!-- LikeButton.vue -->
<script setup>
const props = defineProps({
  count: { type: Number, required: true },
  liked: { type: Boolean, default: false },
})

const emit = defineEmits(['toggle'])
</script>

<template>
  <button type="button" :aria-pressed="liked" @click="emit('toggle', !liked)">
    {{ liked ? '❤️' : '🤍' }} {{ count }}
  </button>
</template>

The parent listens for the event with @:

<LikeButton
  :count="article.likes"
  :liked="article.liked"
  @toggle="(value) => setLiked(article.id, value)"
/>

With this pattern, the data stays owned by the parent and every change happens in one place, which makes it easy to trace.

v-model on components with defineModel

For custom input components, manually writing a modelValue prop and an update:modelValue event is tedious. Since Vue 3.4, the defineModel macro simplifies it:

<!-- SearchInput.vue -->
<script setup>
const keyword = defineModel({ type: String, default: '' })
</script>

<template>
  <div class="search">
    <input v-model="keyword" type="search" placeholder="Search articles...">
    <button v-if="keyword" type="button" @click="keyword = ''">✕</button>
  </div>
</template>

The parent simply uses v-model as it would on a regular input:

<SearchInput v-model="query" />
<p>Searching for: {{ query }}</p>

You can also create multiple named v-models:

// in the child component
const start = defineModel('start')
const end = defineModel('end')
<!-- in the parent component -->
<DateRange v-model:start="startDate" v-model:end="endDate" />

Slots: passing content into a component

Props are for data, while slots are for template content. With slots, a component provides the frame and the parent fills in the parts.

<!-- CardPanel.vue -->
<template>
  <section class="panel">
    <header class="panel__title">
      <slot name="title">Untitled</slot>
    </header>
    <div class="panel__body">
      <slot />
    </div>
    <footer v-if="$slots.actions" class="panel__actions">
      <slot name="actions" />
    </footer>
  </section>
</template>

"Untitled" is fallback content shown when the slot isn't provided. $slots.actions is used to render the footer only when that slot is passed.

<CardPanel>
  <template #title>This week's stats</template>

  <p>12 new articles and 340 comments.</p>

  <template #actions>
    <button type="button">View details</button>
  </template>
</CardPanel>

#title is shorthand for v-slot:title. Content that isn't inside a <template #...> automatically goes into the default slot.

Scoped slots

Sometimes a child component owns data that the parent needs to render its content. Take a generic list component:

<!-- DataList.vue -->
<script setup>
defineProps({ items: { type: Array, required: true } })
</script>

<template>
  <ul>
    <li v-for="(item, index) in items" :key="item.id">
      <slot :item="item" :index="index" />
    </li>
  </ul>
</template>
<DataList :items="users">
  <template #default="{ item, index }">
    {{ index + 1 }}. <strong>{{ item.name }}</strong> ({{ item.email }})
  </template>
</DataList>

DataList handles the loop, while the parent decides how each row looks. This pattern is widely used in table and dropdown libraries.

When to use what?

Need Solution
Send data to a child props
Child notifies the parent emits
Custom input that can be bound defineModel
Pass a piece of template slots
Child shares data with the parent's template scoped slots
Data used by many distant components provide/inject or Pinia

Exercise

Build a ModalDialog component that uses a boolean defineModel to open and close the modal, a named title slot, a default slot for the body, and an actions slot for buttons. Close the modal when the Esc key is pressed.

← Explore more notes