On this page
defineProps, defineEmits, and defineModel: component communication
How components communicate
In Vue, data flows in one direction: from parent to child via props, and from child to parent via emitted events. This unidirectional data flow makes applications predictable and easy to debug.
Parent
│ props (down)
▼
Child
│ emits (up)
▼
ParentdefineProps
defineProps is a compiler macro available inside <script setup>. It declares the props a component accepts. With TypeScript, you provide the type via a generic:
const props = defineProps<{
title: string
count: number
items?: string[] // optional (?)
variant: 'primary' | 'secondary'
}>()
// Access props as properties
console.log(props.title)
console.log(props.count)Default values with withDefaults
Required props that are optional need defaults. Use withDefaults:
const props = withDefaults(defineProps<{
size?: 'sm' | 'md' | 'lg'
disabled?: boolean
tags?: string[]
}>(), {
size: 'md',
disabled: false,
tags: () => [], // Arrays and objects MUST use factory functions
})Important: factories (() => []) are required for arrays and objects to avoid sharing the same reference across instances.
Vue 3.5 reactive props destructure
Vue 3.5 introduced a new compiler feature: you can destructure props directly and keep reactivity. The compiler automatically wraps accesses in a getter:
// Vue 3.5+ only
const { title, count = 0 } = defineProps<{
title: string
count?: number
}>()
// title and count are reactive even though destructured
// Default values work like withDefaults but with regular JS default syntaxdefineEmits
defineEmits declares the custom events a component can emit. The TypeScript generic uses a tuple type for each event's payload:
const emit = defineEmits<{
// event name: [payload types...]
click: [] // no payload
change: [value: string] // one payload
update: [id: number, name: string] // multiple payloads
'item:selected': [item: Item] // kebab-case event names
}>()
// Emit an event
emit('change', 'new value')
emit('update', 42, 'Ada')Listening to emitted events in the parent
<MyComponent
@change="handleChange"
@update="handleUpdate"
@item:selected="onItemSelected"
/>v-model and the update:modelValue convention
v-model on a component is syntactic sugar for a :modelValue prop and an @update:modelValue event listener:
<!-- These two are equivalent -->
<MyInput v-model="searchQuery" />
<MyInput :model-value="searchQuery" @update:model-value="searchQuery = $event" />Inside the child component:
const props = defineProps<{
modelValue: string
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
// To update:
function onInput(event: Event) {
emit('update:modelValue', (event.target as HTMLInputElement).value)
}Multiple v-model bindings
You can have multiple v-model bindings on one component using named arguments:
<!-- In the parent -->
<DateRangePicker v-model:start="startDate" v-model:end="endDate" />// In DateRangePicker.vue
const props = defineProps<{
start: string
end: string
}>()
const emit = defineEmits<{
'update:start': [value: string]
'update:end': [value: string]
}>()v-model modifiers
You can define custom modifiers for v-model:
const props = defineProps<{
modelValue: string
modelModifiers?: { trim?: boolean; uppercase?: boolean }
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
function onInput(value: string) {
let result = value
if (props.modelModifiers?.trim) result = result.trim()
if (props.modelModifiers?.uppercase) result = result.toUpperCase()
emit('update:modelValue', result)
}Usage: <MyInput v-model.trim.uppercase="text" />
defineModel() — Vue 3.4+ shorthand
defineModel() is a compiler macro that replaces the modelValue prop + update:modelValue emit boilerplate with a single line:
// Before defineModel
const props = defineProps<{ modelValue: string }>()
const emit = defineEmits<{ 'update:modelValue': [string] }>()
// After defineModel (Vue 3.4+)
const model = defineModel<string>()
// Read and write through model.value
function clear() {
model.value = ''
}With default values:
const model = defineModel<number>({ default: 0 })
const darkMode = defineModel<boolean>('theme', { default: false })
// Parent: <MyComponent v-model:theme="isDark" />Fallthrough attributes
Props not declared with defineProps fall through to the root element automatically. This is useful for forwarding native HTML attributes:
<!-- Parent -->
<MyButton class="mt-4" disabled aria-label="Submit">Save</MyButton>
<!-- MyButton.vue — class, disabled, aria-label are forwarded to <button> -->
<template>
<button type="button">
<slot></slot>
</button>
</template>To disable fallthrough (e.g., to apply it to a specific nested element), use defineOptions:
defineOptions({ inheritAttrs: false })
// Then use v-bind="$attrs" manually where you want attributes appliedPractice
- Create a SearchBar component: It accepts a
modelValue: stringprop and emitsupdate:modelValueandsearch. Usev-modelto bind it in the parent. - Rewrite with defineModel: Replace the prop/emit pattern in your SearchBar with
defineModel<string>(). Verify the parentv-modelstill works identically. - Multiple v-model: Build a
DateRangecomponent withv-model:fromandv-model:tobindings, each bound to a separate<input type="date">.
In the next lesson, we will learn about composables — the Vue 3 mechanism for extracting and reusing stateful logic across components.
<script setup lang="ts">
// defineProps: declare what data flows IN from the parent
const props = defineProps<{
modelValue: number // used by v-model on the parent
maxStars?: number // optional, defaults below
label: string
readonly?: boolean
}>()
// Default values via withDefaults
withDefaults(defineProps<{ maxStars?: number }>(), {
maxStars: 5,
})
// defineEmits: declare what events flow OUT to the parent
const emit = defineEmits<{
'update:modelValue': [value: number]
change: [value: number]
}>()
function selectStar(star: number) {
if (props.readonly) return
emit('update:modelValue', star)
emit('change', star)
}
</script>
<template>
<fieldset class="rating">
<legend>{{ label }}</legend>
<button
v-for="star in maxStars"
:key="star"
type="button"
class="star"
:class="{ filled: star <= modelValue }"
:aria-label="`Rate ${star} of ${maxStars}`"
:aria-pressed="star === modelValue"
:disabled="readonly"
@click="selectStar(star)"
>★</button>
</fieldset>
</template>
<style scoped>
.rating { border: none; padding: 0; }
.star { background: none; border: none; font-size: 1.5rem; cursor: pointer; color: #d1d5db; }
.star.filled { color: #f59e0b; }
.star:disabled { cursor: default; }
</style>
Sign in to track your progress