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.

Related Template & Helpers:useAttrs()

1. What You'll Learn


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:


3. Complete props Syntax

(1) 7 Ways to Declare props

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

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

(1) Basic Type Declaration

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

TS
defineProps<{
  // Required (default)
  user: User
  pageSize: number
  
  // Optional (add ?)
  variant?: 'primary' | 'secondary'
  showIcon?: boolean
}>()

(3) withDefaults for Default Values

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

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

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

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

Output:

TEXT
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).
TEXT
[Search...              ]
Live: (empty)

6. The Unidirectional Data Flow Principle

(1) Core Principle

100%
graph LR
    A[Parent data] -->|props| B[Child]
    B -->|emit| A
    
    style A fill:#42b883
    style B fill:#42b883

(2) 5 Exceptions

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

JS
// ❌ 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 Yourself

Output:

TEXT
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.
TEXT
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
Email 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)
▶ Try it Yourself

Output:

TEXT
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.
TEXT
iPhone
$999
[Add to Cart] [❤️ Favorite] [👁 Quick View]

MacBook
$2499
[Out of Stock] [❤️ Favorite] [👁 Quick View]   (card dimmed)

Cart items: 0 | Favorites: 0

Related Template & Helpers:useAttrs()

❓ FAQ

Q Can props be mutated directly?
A No. Vue enforces unidirectional data flow, so props are read-only. To change data, a child must emit an event and let the parent do it. Mutating directly triggers a Vue warning.
Q How do I pass complex data (nested objects)?
A Pass the reference; Vue makes it deeply reactive automatically. The parent can observe changes to nested properties (deep reactivity), but the child should not mutate them — it should emit and let the parent change the whole object or property.
Q What is the difference between defineProps and the props option?
A In <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.
Q Can emit be triggered asynchronously in a child?
A Yes. emit is a synchronous function call, but it can live inside setTimeout/Promise: setTimeout(() => emit('done'), 1000).
Q What is v-model really?
A It is syntactic sugar over props + emit. <Child v-model="x" /> is equivalent to :modelValue="x" @update:modelValue="x = $event". Example 3 of this lesson walks through it.
Q How do I watch prop changes in a child?
A Use watch(() => props.xxx, (newVal, oldVal) => {...}). Be careful never to mutate props inside the watcher, or you get an infinite loop.
Q When do I need useAttrs?
A 3 cases: (1) multi-root components (Fragments) must use it; (2) you want attributes passed through to a non-root element; (3) you want explicit control over where attributes are bound. Plain single-root components do not need it.
Q What happens when prop validation fails?
A Vue warns in development and fails silently in production. It is recommended that validators return false (invalid) or a string (the error message).

📖 Summary


📝 Exercises

  1. 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 click event (no payload)
    • Test all 3 variants in the parent
  2. 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
  3. Challenge (difficulty ⭐⭐⭐) Build a complete "product card + favorites" system:

    1. ProductCard.vue: full props (product/showStockBadge/maxStock) + emits (add-to-cart/toggle-favorite)
    2. App.vue: product list + favorites state
    3. Favorite button: emits toggle-favorite, and the parent updates the favorite state
    4. Use useAttrs + inheritAttrs to implement a multi-root component (without breaking the exercise)
    5. At least 3 validator checks (email/url/enum)
    6. TypeScript strongly typed props
Web-Tutorial.com

Web-Tutorial Tech Team

A team of developers maintaining programming tutorials. Each tutorial is written and reviewed by developers with expertise in that field. We work to keep our content accurate and reliable — if you spot an issue, please let us know.

100%

🙏 帮我们做得更好

我们是刚上线的编程教程站,几个人的小团队,精力有限。页面虽经检查,难免还有疏漏——链接失效、排版错乱、内容有误、语言生硬……

如果您发现了,麻烦告诉我们,我们会在收到反馈后第一时间进行修复,再次感谢您的光临 🙏