Back to blog
Frontend Engineeringintermediate

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.

LearnixoApril 16, 20265 min read
Vue.jsVue 3ComponentsComposition APITypeScriptFrontend
Share:𝕏

Props with TypeScript

Vue 3 + TypeScript gives you fully typed props using defineProps:

VUE
<!-- 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

VUE
<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:

VUE
<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:

VUE
<!-- 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

VUE
<!-- 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

VUE
<!-- 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

VUE
<!-- 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

VUE
<!-- 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:

VUE
<!-- 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>
VUE
<!-- 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:

TYPESCRIPT
// src/composables/clinicContext.ts
import { InjectionKey, Ref } from 'vue'
import type { Clinic } from '@/types'

export const clinicKey: InjectionKey<Ref<Clinic | null>> = Symbol('currentClinic')
VUE
<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

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

TYPESCRIPT
// 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

VUE
<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?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.