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.
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.
