On this page

defineProps, defineEmits, and defineModel: component communication

12 min read TextCh. 2 — Reactivity

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)
  ▼
Parent

defineProps

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 syntax

defineEmits

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 applied

Practice

  1. Create a SearchBar component: It accepts a modelValue: string prop and emits update:modelValue and search. Use v-model to bind it in the parent.
  2. Rewrite with defineModel: Replace the prop/emit pattern in your SearchBar with defineModel<string>(). Verify the parent v-model still works identically.
  3. Multiple v-model: Build a DateRange component with v-model:from and v-model:to bindings, 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.

Props are readonly
Never mutate a prop directly inside a child component. Props flow down from parent to child — mutating them breaks the one-directional data flow. Use emits to notify the parent, or use a local copy initialized from the prop.
defineModel() shorthand
Vue 3.4+ introduced defineModel(), which combines :modelValue prop + update:modelValue emit in one line: const model = defineModel<number>(). Use model.value to read and write — the parent sees v-model updates automatically.
Prop naming convention
Declare props in camelCase in defineProps (e.g., maxStars) and use kebab-case in parent templates (e.g., :max-stars). Vue handles the conversion automatically.
<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>