Skip to main content
Advertisement

14.4 Component System — Props, Emits, Slots, Teleport, KeepAlive

Vue 3's component system is the core mechanism for splitting UI into independent, reusable pieces. Components communicate through Props (parent → child data), Emits (child → parent events), and Slots (content injection). Built-in components like Teleport and KeepAlive elegantly solve real-world requirements.


1. Props — Passing Data from Parent to Child

Props are the one-way channel through which a parent component passes values to a child. In Vue 3.5's <script setup>, they are declared with defineProps().

Basic Props Declaration

<!-- ChildCard.vue -->
<script setup>
const props = defineProps({
title: {
type: String,
required: true,
},
count: {
type: Number,
default: 0,
},
tags: {
type: Array,
default: () => [],
},
})
</script>

<template>
<div class="card">
<h2>{{ props.title }}</h2>
<p>Visit count: {{ props.count }}</p>
<ul>
<li v-for="tag in props.tags" :key="tag">{{ tag }}</li>
</ul>
</div>
</template>
<!-- ParentPage.vue -->
<script setup>
import ChildCard from './ChildCard.vue'
</script>

<template>
<ChildCard
title="Vue 3 Components"
:count="42"
:tags="['vue', 'composition', 'setup']"
/>
</template>

With TypeScript — withDefaults + Generic Props (Vue 3.5)

Since Vue 3.5, you can declare generic type parameters directly as <script setup lang="ts" generic="T">.

<!-- DataList.vue -->
<script setup lang="ts" generic="T extends { id: number; label: string }">
defineProps<{
items: T[]
selected?: T | null
}>()

const emit = defineEmits<{
select: [item: T]
}>()
</script>

<template>
<ul>
<li
v-for="item in items"
:key="item.id"
:class="{ active: selected?.id === item.id }"
@click="emit('select', item)"
>
{{ item.label }}
</li>
</ul>
</template>

Props Validation — Custom Validator

<script setup>
defineProps({
status: {
type: String,
validator(value) {
return ['pending', 'active', 'closed'].includes(value)
},
},
size: {
type: Number,
validator: (v) => v > 0 && v <= 100,
},
})
</script>

2. Emits — Passing Events from Child to Parent

Child components declare events with defineEmits() and fire them with emit(). The parent listens using v-on or @.

Basic Emits

<!-- SearchInput.vue -->
<script setup>
const emit = defineEmits(['search', 'clear'])

function handleInput(e) {
emit('search', e.target.value)
}

function handleClear() {
emit('clear')
}
</script>

<template>
<div class="search-box">
<input type="text" placeholder="Enter search term..." @input="handleInput" />
<button @click="handleClear">Reset</button>
</div>
</template>
<!-- App.vue -->
<script setup>
import { ref } from 'vue'
import SearchInput from './SearchInput.vue'

const query = ref('')

function onSearch(value) {
query.value = value
console.log('Search:', value)
}
</script>

<template>
<SearchInput @search="onSearch" @clear="() => (query = '')" />
<p>Current query: {{ query }}</p>
</template>

TypeScript Emits — Type-Safe Events

<script setup lang="ts">
const emit = defineEmits<{
change: [value: string]
submit: [payload: { name: string; email: string }]
'update:modelValue': [value: number]
}>()

// Support for two-way v-model binding
defineProps<{ modelValue: number }>()
</script>

Custom v-model (Single & Multiple)

<!-- RangeInput.vue — multiple v-model -->
<script setup lang="ts">
defineProps<{
min: number
max: number
}>()

const emit = defineEmits<{
'update:min': [value: number]
'update:max': [value: number]
}>()
</script>

<template>
<div>
<input type="number" :value="min" @input="emit('update:min', +$event.target.value)" />
<span> ~ </span>
<input type="number" :value="max" @input="emit('update:max', +$event.target.value)" />
</div>
</template>
<!-- Usage -->
<RangeInput v-model:min="startValue" v-model:max="endValue" />

3. Slots — Injecting Content

Slots let a parent insert content into a child's template. Understanding default slots, named slots, and scoped slots covers nearly every use case.

Default Slot

<!-- BaseCard.vue -->
<template>
<div class="base-card">
<slot><!-- fallback content --><p>No content provided.</p></slot>
</div>
</template>
<BaseCard>
<h3>Custom Title</h3>
<p>Content injected by the parent</p>
</BaseCard>

Named Slots

<!-- PageLayout.vue -->
<template>
<div class="layout">
<header>
<slot name="header" />
</header>
<main>
<slot />
</main>
<footer>
<slot name="footer">
<p>Default footer text</p>
</slot>
</footer>
</div>
</template>
<PageLayout>
<template #header>
<h1>Page Title</h1>
<nav>...</nav>
</template>

<p>Main content area</p>

<template #footer>
<p>© 2025 My App</p>
</template>
</PageLayout>

Scoped Slots — Passing Data Back Up

The child exposes data on the slot, and the parent receives that data for rendering.

<!-- DataTable.vue -->
<script setup>
defineProps({
rows: Array,
columns: Array,
})
</script>

<template>
<table>
<thead>
<tr>
<th v-for="col in columns" :key="col.key">{{ col.label }}</th>
</tr>
</thead>
<tbody>
<tr v-for="row in rows" :key="row.id">
<td v-for="col in columns" :key="col.key">
<!-- Scoped slot: exposes col and row to the parent -->
<slot :name="col.key" :row="row" :value="row[col.key]">
{{ row[col.key] }}
</slot>
</td>
</tr>
</tbody>
</table>
</template>
<!-- Usage -->
<DataTable :rows="users" :columns="columns">
<!-- Custom rendering for the status column only -->
<template #status="{ value }">
<span :class="`badge badge-${value}`">{{ value }}</span>
</template>

<!-- Actions column -->
<template #actions="{ row }">
<button @click="editUser(row)">Edit</button>
<button @click="deleteUser(row.id)">Delete</button>
</template>
</DataTable>

4. Teleport — Rendering Outside the DOM Tree

<Teleport> lets you render a component anywhere in the DOM tree. It is invaluable for modals, tooltips, and notifications where z-index stacking becomes problematic.

<!-- Modal.vue -->
<script setup>
defineProps({
show: Boolean,
title: String,
})

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

<template>
<!-- Renders directly under the body tag -->
<Teleport to="body">
<Transition name="modal">
<div v-if="show" class="modal-overlay" @click.self="emit('close')">
<div class="modal-container" role="dialog" aria-modal="true">
<header class="modal-header">
<h2>{{ title }}</h2>
<button class="close-btn" @click="emit('close')" aria-label="Close">✕</button>
</header>
<div class="modal-body">
<slot />
</div>
<footer class="modal-footer">
<slot name="footer">
<button @click="emit('close')">Close</button>
</slot>
</footer>
</div>
</div>
</Transition>
</Teleport>
</template>

<style scoped>
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-container {
background: #fff;
border-radius: 8px;
padding: 1.5rem;
min-width: 320px;
max-width: 600px;
width: 90%;
}
.modal-enter-active,
.modal-leave-active {
transition: opacity 0.2s ease;
}
.modal-enter-from,
.modal-leave-to {
opacity: 0;
}
</style>
<!-- App.vue -->
<script setup>
import { ref } from 'vue'
import Modal from './Modal.vue'

const isOpen = ref(false)
</script>

<template>
<button @click="isOpen = true">Open Modal</button>

<Modal :show="isOpen" title="Confirm" @close="isOpen = false">
<p>Do you want to continue with this action?</p>
<template #footer>
<button @click="isOpen = false">Cancel</button>
<button @click="confirm">Confirm</button>
</template>
</Modal>
</template>

Multiple Teleports (Several into the Same Target)

<!-- Stack multiple notifications into one container -->
<Teleport to="#notifications">
<div class="toast" v-if="hasNotification">{{ message }}</div>
</Teleport>

5. KeepAlive — Preserving Component State

Components wrapped in <KeepAlive> are cached rather than unmounted. This is ideal for UIs where state must persist across tab switches, multi-step forms, and similar scenarios.

<!-- TabView.vue -->
<script setup>
import { ref, shallowRef } from 'vue'
import TabHome from './TabHome.vue'
import TabProfile from './TabProfile.vue'
import TabSettings from './TabSettings.vue'

const tabs = [
{ key: 'home', label: 'Home', component: TabHome },
{ key: 'profile', label: 'Profile', component: TabProfile },
{ key: 'settings', label: 'Settings', component: TabSettings },
]

const currentTab = shallowRef(tabs[0].component)

function switchTab(tab) {
currentTab.value = tab.component
}
</script>

<template>
<nav class="tab-nav">
<button
v-for="tab in tabs"
:key="tab.key"
@click="switchTab(tab)"
>
{{ tab.label }}
</button>
</nav>

<!-- include/exclude control which components are cached -->
<KeepAlive :include="['TabHome', 'TabProfile']" :max="5">
<component :is="currentTab" />
</KeepAlive>
</template>

activated / deactivated Lifecycle Hooks

<!-- TabHome.vue -->
<script setup>
import { ref, onActivated, onDeactivated } from 'vue'

const scrollY = ref(0)

onActivated(() => {
// Called when the component is restored from cache
window.scrollTo(0, scrollY.value)
console.log('Tab activated')
})

onDeactivated(() => {
// Called when the component is moved to cache
scrollY.value = window.scrollY
console.log('Tab deactivated — saved scroll position:', scrollY.value)
})
</script>

6. Real-World Example — Complete Form Wizard (Multi-Step)

A multi-step form that uses Props, Emits, Slots, and KeepAlive together.

<!-- StepWizard.vue -->
<script setup>
import { ref, computed } from 'vue'

const props = defineProps({
steps: {
type: Array,
required: true,
// [{ title, component, validate }]
},
})

const emit = defineEmits(['complete', 'cancel'])

const currentStep = ref(0)
const formData = ref({})

const isFirst = computed(() => currentStep.value === 0)
const isLast = computed(() => currentStep.value === props.steps.length - 1)
const progress = computed(() =>
Math.round(((currentStep.value + 1) / props.steps.length) * 100)
)

async function next() {
const step = props.steps[currentStep.value]
if (step.validate) {
const valid = await step.validate(formData.value)
if (!valid) return
}
if (isLast.value) {
emit('complete', formData.value)
} else {
currentStep.value++
}
}

function prev() {
if (!isFirst.value) currentStep.value--
}
</script>

<template>
<div class="wizard">
<!-- Progress indicator -->
<div class="wizard-progress">
<div class="progress-bar" :style="{ width: progress + '%' }" />
<span>{{ currentStep + 1 }} / {{ steps.length }}</span>
</div>

<!-- Step title slot -->
<header class="wizard-header">
<slot name="title" :step="steps[currentStep]" :index="currentStep">
<h2>{{ steps[currentStep].title }}</h2>
</slot>
</header>

<!-- Cache each step component with KeepAlive -->
<KeepAlive>
<component
:is="steps[currentStep].component"
v-model="formData"
:key="currentStep"
/>
</KeepAlive>

<!-- Navigation slot -->
<footer class="wizard-footer">
<slot
name="navigation"
:prev="prev"
:next="next"
:is-first="isFirst"
:is-last="isLast"
>
<button v-if="!isFirst" @click="prev">Back</button>
<button @click="emit('cancel')">Cancel</button>
<button @click="next" class="btn-primary">
{{ isLast ? 'Finish' : 'Next' }}
</button>
</slot>
</footer>
</div>
</template>
<!-- App.vue — Using the wizard -->
<script setup>
import { ref } from 'vue'
import StepWizard from './StepWizard.vue'
import StepPersonal from './steps/StepPersonal.vue'
import StepAccount from './steps/StepAccount.vue'
import StepReview from './steps/StepReview.vue'

const steps = [
{
title: 'Personal Info',
component: StepPersonal,
validate: (data) => !!(data.name && data.email),
},
{
title: 'Account Setup',
component: StepAccount,
validate: (data) => data.password?.length >= 8,
},
{
title: 'Final Review',
component: StepReview,
},
]

const result = ref(null)

function onComplete(data) {
result.value = data
console.log('Registration complete:', data)
}
</script>

<template>
<StepWizard :steps="steps" @complete="onComplete" @cancel="console.log('Cancelled')">
<template #title="{ step, index }">
<h2>Step {{ index + 1 }}: {{ step.title }}</h2>
</template>
</StepWizard>

<pre v-if="result">{{ JSON.stringify(result, null, 2) }}</pre>
</template>

7. Expert Tips

Props Destructuring with Reactivity (Vue 3.5)

In Vue 3.5, destructuring the return value of defineProps() still preserves reactivity.

<script setup>
// Before Vue 3.5: had to use props.title to keep reactivity
// Vue 3.5+: destructuring preserves reactivity
const { title, count = 0 } = defineProps<{
title: string
count?: number
}>()

// title and count are both reactive — use directly in templates
</script>

defineModel() — Eliminate v-model Boilerplate

<!-- Old approach: had to declare both defineProps + defineEmits -->
<!-- Vue 3.4+ defineModel: one line supports v-model -->
<script setup lang="ts">
const value = defineModel<string>({ default: '' })
const count = defineModel<number>('count', { default: 0 })
</script>

<template>
<input v-model="value" />
<button @click="count++">{{ count }}</button>
</template>

defineExpose — Restricting External Access

<!-- child.vue -->
<script setup>
import { ref } from 'vue'

const internalState = ref('private')
const publicMethod = () => console.log('public method')

// Only expose what you explicitly declare
defineExpose({ publicMethod })
</script>
<!-- parent.vue -->
<script setup>
import { ref, onMounted } from 'vue'
import Child from './Child.vue'

const childRef = ref(null)

onMounted(() => {
childRef.value.publicMethod() // works
// childRef.value.internalState — undefined (private)
})
</script>

<template>
<Child ref="childRef" />
</template>

Teleport disabled Prop for Conditional Teleporting

<Teleport to="body" :disabled="isMobile">
<!-- On mobile: renders in place; on desktop: renders under body -->
<Dropdown />
</Teleport>

Checking Whether a Slot Exists

<script setup>
import { useSlots } from 'vue'

const slots = useSlots()
const hasHeader = computed(() => !!slots.header)
</script>

<template>
<div :class="{ 'no-header': !hasHeader }">
<header v-if="hasHeader">
<slot name="header" />
</header>
<slot />
</div>
</template>
Advertisement