Vue 3 Components Deep Dive: Props, Emits, Slots & Lifecycle
Master Vue 3 component patterns — defineProps and defineEmits with TypeScript, v-model on custom components, named and scoped slots, provide/inject, component lifecycle hooks, and async components.
Props with TypeScript
Vue 3 + TypeScript gives you fully typed props using defineProps:
<!-- AppointmentCard.vue -->
<script setup lang="ts">
interface Props {
id: string
patientName: string
dateTime: Date
status: 'scheduled' | 'completed' | 'cancelled'
clinicName?: string // optional
isHighlighted?: boolean
}
const props = withDefaults(defineProps<Props>(), {
isHighlighted: false,
clinicName: 'Unknown Clinic',
})
</script>
<template>
<div
class="rounded-xl border p-4"
:class="{ 'border-primary ring-2 ring-primary/20': props.isHighlighted }"
>
<p class="font-semibold">{{ props.patientName }}</p>
<p class="text-sm text-muted-foreground">
{{ props.dateTime.toLocaleString() }}
</p>
<StatusBadge :status="props.status" />
</div>
</template>Emits with TypeScript
<script setup lang="ts">
interface Emits {
cancel: [appointmentId: string, reason: string]
reschedule: [appointmentId: string, newDate: Date]
select: [appointmentId: string]
}
const emit = defineEmits<Emits>()
function onCancelClick() {
emit('cancel', props.id, cancelReason.value)
}
</script>
<template>
<button @click="onCancelClick">Cancel</button>
<button @click="emit('select', props.id)">View</button>
</template>Consuming the component:
<AppointmentCard
:id="appt.id"
:patient-name="appt.patientName"
:date-time="appt.dateTime"
:status="appt.status"
@cancel="handleCancel"
@select="openDetail"
/>v-model on Custom Components
Implement v-model on your own components for two-way binding:
<!-- SearchInput.vue — supports v-model -->
<script setup lang="ts">
const props = defineProps<{
modelValue: string
placeholder?: string
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
</script>
<template>
<input
:value="props.modelValue"
:placeholder="props.placeholder"
class="input"
@input="emit('update:modelValue', ($event.target as HTMLInputElement).value)"
/>
</template>Usage: <SearchInput v-model="query" placeholder="Search patients..." />
Multiple v-model Bindings
<!-- DateRangePicker.vue -->
<script setup lang="ts">
defineProps<{
startDate: Date | null
endDate: Date | null
}>()
defineEmits<{
'update:startDate': [date: Date | null]
'update:endDate': [date: Date | null]
}>()
</script>
<!-- Usage -->
<DateRangePicker
v-model:start-date="from"
v-model:end-date="to"
/>Slots
Default Slot
<!-- Card.vue -->
<template>
<div class="rounded-xl border bg-card p-6">
<slot />
</div>
</template>
<!-- Usage -->
<Card>
<h2>Appointment Details</h2>
<p>...</p>
</Card>Named Slots
<!-- DashboardPanel.vue -->
<template>
<div class="panel">
<header class="panel-header">
<slot name="header" />
</header>
<div class="panel-body">
<slot /> <!-- default slot -->
</div>
<footer class="panel-footer">
<slot name="footer" />
</footer>
</div>
</template>
<!-- Usage -->
<DashboardPanel>
<template #header>
<h3>Today's Calls</h3>
<Badge>Live</Badge>
</template>
<CallList :calls="calls" />
<template #footer>
<Button @click="exportCsv">Export CSV</Button>
</template>
</DashboardPanel>Scoped Slots — passing data up to parent
<!-- DataTable.vue — passes row data to parent for custom rendering -->
<script setup lang="ts">
defineProps<{ rows: any[] }>()
</script>
<template>
<table>
<tr v-for="row in rows" :key="row.id">
<slot name="row" :row="row" :index="index" />
</tr>
</table>
</template>
<!-- Usage — parent controls how each row renders -->
<DataTable :rows="appointments">
<template #row="{ row }">
<td>{{ row.patientName }}</td>
<td>{{ row.dateTime }}</td>
<td><StatusBadge :status="row.status" /></td>
</template>
</DataTable>provide / inject
Pass data deep into a component tree without prop drilling:
<!-- App.vue or a layout component -->
<script setup lang="ts">
import { provide, ref } from 'vue'
const currentClinic = ref<Clinic | null>(null)
// Provide a readonly ref to prevent mutation from children
provide('currentClinic', readonly(currentClinic))
provide('setClinic', (clinic: Clinic) => { currentClinic.value = clinic })
</script><!-- DeepChildComponent.vue -->
<script setup lang="ts">
import { inject } from 'vue'
import type { Clinic } from '@/types'
const clinic = inject<Readonly<Ref<Clinic | null>>>('currentClinic')
const setClinic = inject<(c: Clinic) => void>('setClinic')
</script>For type safety, use an injection key:
// src/composables/clinicContext.ts
import { InjectionKey, Ref } from 'vue'
import type { Clinic } from '@/types'
export const clinicKey: InjectionKey<Ref<Clinic | null>> = Symbol('currentClinic')<script setup lang="ts">
import { provide, inject, ref } from 'vue'
import { clinicKey } from '@/composables/clinicContext'
// Provider
provide(clinicKey, ref(null))
// Consumer — TypeScript knows the type
const clinic = inject(clinicKey) // Ref<Clinic | null>
</script>Lifecycle Hooks
<script setup lang="ts">
import {
onMounted, onUnmounted, onBeforeMount,
onUpdated, onBeforeUpdate, onActivated
} from 'vue'
// Runs after component is mounted to the DOM
onMounted(() => {
fetchAppointments()
window.addEventListener('resize', handleResize)
})
// Cleanup before unmount — prevent memory leaks
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
clearInterval(pollInterval)
})
// Runs after each reactive update
onUpdated(() => {
// DOM is updated — safe to measure DOM
})
// For keep-alive — runs when component is re-activated
onActivated(() => {
refreshData() // re-fetch when navigating back
})
</script>Async Components & Lazy Loading
// src/router/index.ts
import { defineAsyncComponent } from 'vue'
// Loaded only when the route is visited
const Dashboard = defineAsyncComponent(() => import('@/pages/Dashboard.vue'))
const CallAnalytics = defineAsyncComponent({
loader: () => import('@/pages/CallAnalytics.vue'),
loadingComponent: LoadingSpinner,
errorComponent: ErrorMessage,
delay: 200, // show loader after 200ms
timeout: 10_000, // show error after 10s
})Suspense
<template>
<Suspense>
<template #default>
<AsyncDashboard />
</template>
<template #fallback>
<div class="p-8 text-center">Loading dashboard...</div>
</template>
</Suspense>
</template>Component Patterns Summary
| Pattern | Use When |
|---------|----------|
| Props / Emits | Parent ↔ Child communication |
| v-model | Two-way binding on form components |
| Named slots | Parent controls layout inside component |
| Scoped slots | Component shares data with slot content |
| provide/inject | Share data across deep component trees |
| defineExpose | Expose methods for parent to call via ref |
| Async components | Heavy or rarely-used components |
Enjoyed this article?
Explore the Frontend Engineering learning path for more.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.