Props and Emits
Components communicate through props (data down) and emits (events up). Vue 3 with TypeScript makes this type-safe and concise with defineProps and defineEmits, which are compiler macros -- they look like function calls but are processed at build time and do not need to be imported.
defineProps with TypeScript
The cleanest way to define props is with a TypeScript-only declaration:
<script setup lang="ts">
const props = defineProps<{
title: string
count: number
tags?: string[]
disabled?: boolean
}>()
</script>
<template>
<div :class="{ disabled }">
<h2>{{ title }} ({{ count }})</h2>
<span v-for="tag in tags" :key="tag" class="tag">{{ tag }}</span>
</div>
</template>
Required props have no ?. Optional props use ?. TypeScript enforces this at compile time, so you get red squiggles in your IDE when a parent component forgets to pass title.
Default Values with withDefaults
Optional props need defaults. Use withDefaults:
<script setup lang="ts">
interface Props {
title: string
size?: 'sm' | 'md' | 'lg'
tags?: string[]
onClose?: () => void
}
const props = withDefaults(defineProps<Props>(), {
size: 'md',
tags: () => [],
})
</script>
Note: array and object defaults must be factory functions (() => []), not plain values. This prevents shared references between component instances -- the same reason React class components required function returns from getInitialState.
Runtime Props (Alternative Syntax)
You can also define props with the runtime declaration, which supports custom validators:
<script setup lang="ts">
const props = defineProps({
email: {
type: String,
required: true,
validator: (value: string) => value.includes('@'),
},
role: {
type: String as () => 'admin' | 'user' | 'guest',
default: 'guest',
},
})
</script>
The runtime syntax runs validators at runtime (development mode only), which can catch issues that TypeScript cannot -- like a prop that passes type checking but has an invalid value. In practice, most teams use the TypeScript-only syntax and rely on types rather than runtime validators.
defineEmits
Events flow upward. defineEmits declares what events a component can emit and their payload types:
<script setup lang="ts">
const emit = defineEmits<{
(e: 'update', id: number, value: string): void
(e: 'delete', id: number): void
(e: 'close'): void
}>()
function handleSave(id: number) {
emit('update', id, 'new value')
}
function handleRemove(id: number) {
emit('delete', id)
}
</script>
Vue 3.3 introduced a shorter syntax:
<script setup lang="ts">
const emit = defineEmits<{
update: [id: number, value: string]
delete: [id: number]
close: []
}>()
</script>
Both syntaxes produce the same result. The tuple syntax is more compact and what most codebases have converged on.
The parent listens with @:
<template>
<ItemCard
:id="item.id"
:title="item.title"
@update="handleUpdate"
@delete="handleDelete"
@close="showPanel = false"
/>
</template>
v-model on Components
v-model on native inputs is shorthand for a value prop and an input event. On components, it works the same way but with modelValue as the prop name and update:modelValue as the event:
<!-- Parent -->
<script setup lang="ts">
import { ref } from 'vue'
import RatingInput from './RatingInput.vue'
const rating = ref(3)
</script>
<template>
<RatingInput v-model="rating" />
<p>Selected: {{ rating }} stars</p>
</template>
<!-- RatingInput.vue -->
<script setup lang="ts">
const props = defineProps<{
modelValue: number
}>()
const emit = defineEmits<{
'update:modelValue': [value: number]
}>()
function select(stars: number) {
emit('update:modelValue', stars)
}
</script>
<template>
<div class="rating">
<button
v-for="n in 5"
:key="n"
:class="{ active: n <= modelValue }"
@click="select(n)"
>
{{ n <= modelValue ? 'X' : 'O' }}
</button>
</div>
</template>
Named v-model
Vue 3 supports multiple v-models on a single component using named bindings:
<!-- Parent -->
<template>
<UserForm
v-model:first-name="first"
v-model:last-name="last"
v-model:email="email"
/>
</template>
<!-- UserForm.vue -->
<script setup lang="ts">
const props = defineProps<{
firstName: string
lastName: string
email: string
}>()
const emit = defineEmits<{
'update:firstName': [value: string]
'update:lastName': [value: string]
'update:email': [value: string]
}>()
</script>
<template>
<input :value="firstName" @input="emit('update:firstName', ($event.target as HTMLInputElement).value)" />
<input :value="lastName" @input="emit('update:lastName', ($event.target as HTMLInputElement).value)" />
<input :value="email" @input="emit('update:email', ($event.target as HTMLInputElement).value)" />
</template>
This replaces the .sync modifier from Vue 2. It is particularly useful for form components that manage multiple fields.
defineModel (Vue 3.4+)
Vue 3.4 introduced defineModel, which eliminates the boilerplate of defining the prop and emit separately:
<!-- RatingInput.vue with defineModel -->
<script setup lang="ts">
const rating = defineModel<number>({ required: true })
</script>
<template>
<div class="rating">
<button
v-for="n in 5"
:key="n"
:class="{ active: n <= (rating ?? 0) }"
@click="rating = n"
>
{{ n <= (rating ?? 0) ? 'X' : 'O' }}
</button>
</div>
</template>
defineModel returns a ref that you can read and write directly. It handles the prop and emit wiring behind the scenes. For named models:
<script setup lang="ts">
const firstName = defineModel<string>('firstName', { required: true })
const lastName = defineModel<string>('lastName', { required: true })
</script>
This is the recommended approach for new code. It is significantly less verbose.
Prop Immutability
Props are read-only. Mutating a prop directly triggers a warning in development:
// This is wrong:
props.title = 'New Title' // Vue warns: "Attempting to mutate prop"
If you need local mutable state based on a prop, copy it:
<script setup lang="ts">
import { ref, watch } from 'vue'
const props = defineProps<{ initialCount: number }>()
// Copy prop to local state
const count = ref(props.initialCount)
// Optionally sync if parent can change it
watch(() => props.initialCount, (newVal) => {
count.value = newVal
})
</script>
Or use computed if the derived value does not need to be independently mutable:
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps<{ items: string[] }>()
const sortedItems = computed(() => [...props.items].sort())
</script>
Boolean Casting
Vue has special handling for boolean props that matches HTML attribute behavior:
<script setup lang="ts">
defineProps<{
disabled?: boolean
visible?: boolean
}>()
</script>
<!-- All equivalent for boolean props -->
<MyButton disabled />
<MyButton :disabled="true" />
<MyButton disabled="" />
Just including the attribute name without a value sets it to true, matching how HTML attributes like disabled and readonly work.
Common Pitfalls
- Mutating props directly: Props are read-only. Copy to a local ref if you need mutability, or emit an event to let the parent update.
- Object/array defaults without factory functions:
default: []creates a shared array across all instances. Always usedefault: () => []. - Forgetting that
definePropsanddefineEmitsare macros: They do not need to be imported. Importing them fromvuewill not cause errors, but it is unnecessary noise. - Using runtime and type-based props together: You cannot mix
defineProps({ ... })withdefineProps<{ ... }>(). Pick one syntax. - Not using
defineModelin new code: If you are on Vue 3.4+,defineModeleliminates significant boilerplate for two-way binding. There is no reason to manually wiremodelValue+update:modelValueanymore. - Emitting events with wrong payload types: TypeScript catches this at compile time when you use typed
defineEmits, but only if you actually define the types. Untyped emits give you no safety net.
Key Takeaways
- Use TypeScript-only
defineProps<{}>()for type-safe, concise prop definitions. AddwithDefaultsfor default values. defineEmitswith tuple syntax provides typed event emission. The parent listens with@eventName.v-modelon components usesmodelValueprop andupdate:modelValueevent. Named v-models support multiple bindings.defineModel(Vue 3.4+) is the modern shorthand for v-model support -- use it in new code.- Props are immutable. Copy to a local ref or use computed for derived values.
- These are all compiler macros: no import needed, processed at build time, zero runtime overhead.