Vue.js: Component Communication
Last updated: 2026-10-20
Component communication is a core mechanism of Vue — parent components pass data down with props, and child components notify parents with emits. Understanding this two-way data flow is the key to writing good Vue applications.
Vue 3's <script setup> provides two compile-time macros, defineProps and defineEmits, which are usable without importing anything and support TypeScript type inference perfectly.
1. What You'll Learn
- The complete props syntax: types, required, defaults, validators
- The many ways to declare props (runtime / type-based / shorthand)
- Declaring and validating emitted events
- TypeScript type inference (
defineProps<T>()) - props passthrough (inheritAttrs / useAttrs)
- The unidirectional data flow principle
- 5 common mistakes and best practices
2. The "Two-Way Binding" Trap in an E-commerce Cart
(1) Pain Point: The Child Mutates a prop, the Parent Breaks
Alice built a shopping cart with a quantity input:
Vue raises a warning:
[Vue warn] Set operation on key "quantity" failed: target is readonly.
After an hour of debugging, Charlie found:
"Alice, when I change the quantity the cart total in the parent doesn't update. Vue is changing the prop, but the parent has no idea."
(2) Vue's Solution: Unidirectional Data Flow + emit
Now the data flow is correct: the child emits → the parent receives → the parent updates its data → the new value is passed back down to the child.
(3) Benefits
After fixing the data flow:
- Vue warnings: 100/day → 0
- Data consistency: the parent stays in sync in real time
- Debuggability: the flow is one-directional and easy to trace
- Testability: props/emit can be tested per component
3. Complete props Syntax
(1) 7 Ways to Declare props
<script setup>
defineProps({
// 1. Primitive types
name: String,
age: Number,
active: Boolean,
// 2. Complex types
user: Object,
items: Array,
// 3. Multiple types (any of them)
id: [String, Number],
// 4. Required
title: { type: String, required: true },
// 5. Default value
pageSize: { type: Number, default: 20 },
// 6. Custom validation
email: {
type: String,
validator: (val) => val.includes('@')
},
// 7. Function default (the default must be a function) |
createdAt: {
type: Date,
default: () => new Date()
}
})
</script>
(2) Detailed Example
<script setup>
defineProps({
// String
title: String,
// Number + default + validation
pageSize: {
type: Number,
default: 20,
validator: (val) => val > 0 && val <= 100
},
// Boolean (note: for falsy defaults use the function form)
isVip: {
type: Boolean,
default: false
},
// Array default (must return it from a function)
tags: {
type: Array,
default: () => ['vue', 'javascript']
},
// Object default (must return it from a function)
user: {
type: Object,
default: () => ({ name: 'Guest', age: 0 })
},
// Required + custom validation
email: {
type: String,
required: true,
validator: (val) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(val)
}
})
</script>
(3) 6 Default Value Rules
| Rule | Example |
|---|---|
| Primitive values | default: 20 |
| Arrays/objects | default: () => [] |
| null | default: null |
| undefined | No default needed |
| Function references | Not default: fn, use () => fn() |
| Symbol / BigInt | Return from a factory function |
4. Strongly Typed props with TypeScript (Recommended in Vue 3)
(1) Basic Type Declaration
<script setup lang="ts">
interface User {
id: number
name: string
email: string
role: 'admin' | 'user'
}
defineProps<{
user: User
size?: 'small' | 'medium' | 'large'
showEmail?: boolean
}>()
</script>
(2) Required vs Optional (?)
defineProps<{
// Required (default)
user: User
pageSize: number
// Optional (add ?)
variant?: 'primary' | 'secondary'
showIcon?: boolean
}>()
(3) withDefaults for Default Values
<script setup lang="ts">
interface Props {
user: User
size?: 'small' | 'medium' | 'large'
showEmail?: boolean
}
const props = withDefaults(defineProps<Props>(), {
size: 'medium',
showEmail: true
})
</script>
(4) Runtime Declaration vs Type Declaration
| Dimension | Runtime defineProps({}) |
Type-based defineProps<T>() |
|---|---|---|
| Type inference | Weak | Strong (IDE autocomplete) |
| Default values | default directly |
Wrapped in withDefaults |
| Validation | validator function | TypeScript compile-time check |
| Recommended for | Small projects / plain JS | Large projects / TS projects |
5. Complete emit Syntax
(1) 5 Ways to Declare Events
<script setup>
// 1. Simple declaration
const emit = defineEmits(['click', 'submit', 'cancel'])
// 2. With payload validation
const emit = defineEmits({
addToCart: (productId) => typeof productId === 'number',
remove: (id) => typeof id === 'string'
})
// 3. TypeScript types
const emit = defineEmits<{
'add-to-cart': [productId: number]
'remove-item': [id: string, reason: string]
'update': [id: number, data: object]
}>()
</script>
(2) 5 Ways to Emit
<script setup>
const emit = defineEmits(['click', 'submit', 'add-to-cart'])
// 1. Emit with no payload
emit('click')
// 2. Emit with one argument
emit('add-to-cart', 123)
// 3. Emit with several arguments
emit('submit', { name: 'Alice' }, 2026)
// 4. Conditional emit
if (isValid) emit('submit')
// 5. Emit from a function
function handleAdd() {
emit('add-to-cart', props.product.id)
}
</script>
(3) Listening in the Parent
(4) emit Type Validation
<script setup>
const emit = defineEmits({
// ✅ Validation: id must be a number
remove: (id) => typeof id === 'number' || 'Validation failed',
// ❌ Throws: when the payload does not match
// remove: 'invalid', // Wrong: must be a function
})
</script>
▶ Example: v-model Two-Way Binding
▶ Try it YourselfOutput:
One text input with the placeholder Search... and the line Live: (empty) below it.
Every keystroke fires update:modelValue, the parent's searchQuery updates, and the text is echoed immediately — typing vue turns the line into Live: vue;
clearing the field brings back (empty).
[Search... ]
Live: (empty)
6. The Unidirectional Data Flow Principle
(1) Core Principle
graph LR
A[Parent data] -->|props| B[Child]
B -->|emit| A
style A fill:#42b883
style B fill:#42b883
- Data can only flow from parent to child (props go down)
- Events can only flow from child to parent (emit goes up)
- A child must not mutate props directly
(2) 5 Exceptions
<script setup>
const props = defineProps({ user: Object })
// ❌ Wrong: mutating a prop directly
props.user.name = 'Bob' // Vue warning!
// ✅ Exception 1: when the prop is a reference type, you may replace it with a new object
// The parent passes a reference; the child cannot change the reference, but it can emit and let the parent change it
// ✅ Exception 2: the initial prop value can be copied into local state
const localUser = ref({ ...props.user })
// Then mutate localUser (the parent is unaffected)
// ✅ Exception 3: derive a value with computed
const userName = computed(() => props.user.name)
// ✅ Exception 4: use v-model (syntactic sugar)
// <Child v-model="value" /> is equivalent to :value + @update:value
// ✅ Exception 5: provide/inject across levels
// The parent provides, the child injects (learned in Phase 2.5)
</script>
(3) 5 Anti-patterns
// ❌ Anti-pattern 1: mutating a prop directly
props.user.name = 'Bob'
// ❌ Anti-pattern 2: mutating a prop inside a watcher in the child
watch(() => props.value, (val) => { props.value = val * 2 })
// ❌ Anti-pattern 3: passing a two-way bound ref through (not recommended)
const localRef = ref(props.value)
watch(localRef, (val) => emit('update', val))
// ❌ Anti-pattern 4: ignoring the naming contract when using v-model
// v-model expects an update:xxx event, so custom event names must match
emit('change', val) // ❌ the parent's @change will not fire
emit('update:value', val) // ✅ matches v-model
// ❌ Anti-pattern 5: managing the parent's data from inside the child
emit('update', { ...props.user, name: 'Bob' }) // Wrong: pass only the ID and let the parent update it
7. props Passthrough: inheritAttrs and useAttrs
(1) Default Behavior
(2) Disabling Automatic Passthrough
(3) Using useAttrs Explicitly
(4) 5 Use Cases
| Scenario | Approach |
|---|---|
| Default | Do nothing; class is applied to the root automatically |
| Custom root element | Bind explicitly with useAttrs() |
| Pass through to a non-root element | inheritAttrs: false + v-bind="$attrs" |
| Multi-root component (Fragment) | Must use inheritAttrs: false + explicit binding |
| Performance tuning | Disable passthrough to avoid unnecessary DOM attributes |
▶ Example: useAttrs Passthrough (Multi-Root Component)
▶ Try it YourselfOutput:
A label reading Name:, an input with the placeholder Enter name...
and a green border (the input-field class reached the <input> rather than the wrapper div), and the line Input value: which fills in as you type.
Name:
[Enter name... ] (green border)
Input value:
(1) 📊 5 Common Mistakes Quick Reference
| Mistake | Symptom | Fix |
|---|---|---|
| Mutating a prop directly | Vue warning | Use emit and let the parent change it |
| An array/object prop default shares a reference | Multiple components share it | Use the default: () => [] factory |
| The v-model event name does not match | The parent never receives it | Use update:xxx |
| The validator returns a string | Vue does not report an error | Return true/false |
| Passthrough on a multi-root component | Vue warning | inheritAttrs: false |
(2) 📊 5 props Validation Scenarios
| Scenario | Validation |
|---|---|
val => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(val) |
|
| URL | val => /^https?:\/\//.test(val) |
| Enum value | val => ['a', 'b', 'c'].includes(val) |
| Range | val => val >= 0 && val <= 100 |
| Custom object | val => val.id && val.name |
8. Complete Example: E-commerce Product Card props/emits
▶ Example: ProductCard with Complete props/emits
▶ Expand runnable example (83 lines)
Output:
Two cards.
The iPhone card shows $999, an enabled Add to Cart button, ❤️ Favorite and 👁 Quick View;
the MacBook card is dimmed, its button is disabled and reads Out of Stock, and no stock badge appears because stock is 0.
The footer starts at Cart items: 0 | Favorites: 0;
each Add to Cart increments the cart, each Favorite pushes the product id, and every emit passes its validator before the parent handler runs.
iPhone
$999
[Add to Cart] [❤️ Favorite] [👁 Quick View]
MacBook
$2499
[Out of Stock] [❤️ Favorite] [👁 Quick View] (card dimmed)
Cart items: 0 | Favorites: 0
❓ FAQ
<script setup> use the defineProps() macro (no import needed); in the Options API use the props: {} option. They are equivalent, and setup + defineProps is recommended.setTimeout(() => emit('done'), 1000).<Child v-model="x" /> is equivalent to :modelValue="x" @update:modelValue="x = $event". Example 3 of this lesson walks through it.watch(() => props.xxx, (newVal, oldVal) => {...}). Be careful never to mutate props inside the watcher, or you get an infinite loop.📖 Summary
- props go down with
defineProps(), events go up withdefineEmits() - 7 kinds of props declarations: primitive / complex / multiple types / required / default / validator / factory
- TypeScript uses
defineProps<T>()for strong typing +withDefaults()for defaults - emit has 5 ways to trigger plus type validation
- Unidirectional data flow: data goes down, events go up
- 5 anti-patterns: mutate a prop / mutate a prop in a watcher / two-way ref passthrough / mismatched v-model event name / child managing parent data
- inheritAttrs / useAttrs handle props passthrough
- v-model is syntactic sugar over props + emit
📝 Exercises
-
Basic (difficulty ⭐) Implement a simple Button component:
- props:
text(String),variant(String, 'primary'/'success'/'danger'),disabled(Boolean) - 3 variants mapped to 3 CSS classes
- emit a
clickevent (no payload) - Test all 3 variants in the parent
- props:
-
Intermediate (difficulty ⭐⭐) Implement a two-way bound Input component:
- Child CustomInput.vue: props
modelValue,label - Use the v-model syntactic sugar
- emit
update:modelValue - The parent displays the user input in real time
- Child CustomInput.vue: props
-
Challenge (difficulty ⭐⭐⭐) Build a complete "product card + favorites" system:
- ProductCard.vue: full props (product/showStockBadge/maxStock) + emits (add-to-cart/toggle-favorite)
- App.vue: product list + favorites state
- Favorite button: emits toggle-favorite, and the parent updates the favorite state
- Use useAttrs + inheritAttrs to implement a multi-root component (without breaking the exercise)
- At least 3 validator checks (email/url/enum)
- TypeScript strongly typed props