Vue.js Component Communication: Props, Events, and Event Bus
Our Vue app was a mess. Components were tightly coupled, passing data through 5 levels of nesting. Adding a feature meant touching 10 files.
I learned proper component communication patterns. Now our components are loosely coupled, easy to test, and maintainable.
Table of Contents
The Problem
We had deep component nesting:
App
└── Dashboard
└── UserPanel
└── UserList
└── UserItem
└── UserActions
To pass user data from Dashboard to UserActions: props through 4 levels.
To emit action from UserActions to Dashboard: events through 4 levels.
Nightmare to maintain.
Props Down
Parent passes data to child via props:
<!-- Parent.vue -->
<template>
<child-component :message="parentMessage" :count="42"></child-component>
</template>
<script>
import ChildComponent from './ChildComponent.vue'
export default {
components: { ChildComponent },
data() {
return {
parentMessage: 'Hello from parent'
}
}
}
</script>
<!-- ChildComponent.vue -->
<template>
<div>
<p>{{ message }}</p>
<p>Count: {{ count }}</p>
</div>
</template>
<script>
export default {
props: {
message: {
type: String,
required: true
},
count: {
type: Number,
default: 0
}
}
}
</script>
Prop Validation
Always validate props:
props: {
// Basic type check
name: String,
// Multiple types
value: [String, Number],
// Required string
email: {
type: String,
required: true
},
// Number with default
age: {
type: Number,
default: 0
},
// Object with default (must be function)
user: {
type: Object,
default: () => ({})
},
// Custom validator
status: {
type: String,
validator: (value) => {
return ['active', 'inactive', 'pending'].includes(value)
}
}
}
Events Up
Child emits events to parent:
<!-- ChildComponent.vue -->
<template>
<button @click="handleClick">Click me</button>
</template>
<script>
export default {
methods: {
handleClick() {
this.$emit('button-clicked', { timestamp: Date.now() })
}
}
}
</script>
<!-- Parent.vue -->
<template>
<child-component @button-clicked="onButtonClicked"></child-component>
</template>
<script>
export default {
methods: {
onButtonClicked(payload) {
console.log('Button clicked at:', payload.timestamp)
}
}
}
</script>
Event Bus for Sibling Communication
For components that aren’t parent-child:
// event-bus.js
import Vue from 'vue'
export const EventBus = new Vue()
Component A (emitter):
<script>
import { EventBus } from './event-bus'
export default {
methods: {
sendMessage() {
EventBus.$emit('message-sent', { text: 'Hello!' })
}
}
}
</script>
Component B (listener):
<script>
import { EventBus } from './event-bus'
export default {
created() {
EventBus.$on('message-sent', this.handleMessage)
},
beforeDestroy() {
EventBus.$off('message-sent', this.handleMessage)
},
methods: {
handleMessage(payload) {
console.log('Received:', payload.text)
}
}
}
</script>
Important: Always $off in beforeDestroy to prevent memory leaks!
Real-World Example: User Management
Before (prop drilling):
<!-- Dashboard.vue -->
<template>
<user-panel :users="users" @user-deleted="handleUserDeleted"></user-panel>
</template>
<!-- UserPanel.vue -->
<template>
<user-list :users="users" @user-deleted="$emit('user-deleted', $event)"></user-list>
</template>
<!-- UserList.vue -->
<template>
<user-item
v-for="user in users"
:key="user.id"
:user="user"
@user-deleted="$emit('user-deleted', $event)"
></user-item>
</template>
<!-- UserItem.vue -->
<template>
<user-actions :user="user" @delete="$emit('user-deleted', user)"></user-actions>
</template>
Passing through 4 levels!
After (event bus):
// event-bus.js
import Vue from 'vue'
export const EventBus = new Vue()
<!-- Dashboard.vue -->
<template>
<user-panel :users="users"></user-panel>
</template>
<script>
import { EventBus } from './event-bus'
export default {
created() {
EventBus.$on('user-deleted', this.handleUserDeleted)
},
beforeDestroy() {
EventBus.$off('user-deleted', this.handleUserDeleted)
},
methods: {
handleUserDeleted(user) {
// Handle deletion
}
}
}
</script>
<!-- UserActions.vue (deep in tree) -->
<template>
<button @click="deleteUser">Delete</button>
</template>
<script>
import { EventBus } from './event-bus'
export default {
props: ['user'],
methods: {
deleteUser() {
EventBus.$emit('user-deleted', this.user)
}
}
}
</script>
Much cleaner!
Vuex for Complex State
For larger apps, use Vuex (we’ll migrate later):
// store.js
import Vue from 'vue'
import Vuex from 'vuex'
Vue.use(Vuex)
export default new Vuex.Store({
state: {
users: []
},
mutations: {
DELETE_USER(state, userId) {
state.users = state.users.filter(u => u.id !== userId)
}
},
actions: {
deleteUser({ commit }, userId) {
commit('DELETE_USER', userId)
}
}
})
Component:
<script>
export default {
methods: {
deleteUser(userId) {
this.$store.dispatch('deleteUser', userId)
}
},
computed: {
users() {
return this.$store.state.users
}
}
}
</script>
Provide / Inject (Advanced)
For deeply nested components (Vue 2.2+):
<!-- Ancestor.vue -->
<script>
export default {
provide() {
return {
theme: this.theme
}
},
data() {
return {
theme: 'dark'
}
}
}
</script>
<!-- Deep descendant -->
<script>
export default {
inject: ['theme'],
created() {
console.log(this.theme) // 'dark'
}
}
</script>
Warning: Not reactive by default. Use with caution.
Best Practices
- Props down, events up - Default pattern
- Validate props - Always use prop validation
- Event bus for siblings - When props/events don’t work
- Clean up listeners - Always
$offinbeforeDestroy - Use Vuex for complex state - When event bus gets messy
Common Mistakes
1. Mutating props:
// BAD
props: ['user'],
methods: {
updateName() {
this.user.name = 'New name' // Don't mutate props!
}
}
// GOOD
props: ['user'],
methods: {
updateName() {
this.$emit('update-name', 'New name')
}
}
2. Forgetting to $off:
// BAD
created() {
EventBus.$on('event', this.handler)
}
// Memory leak!
// GOOD
created() {
EventBus.$on('event', this.handler)
},
beforeDestroy() {
EventBus.$off('event', this.handler)
}
3. Too many event bus events:
If you have 20+ events, use Vuex instead.
Our Refactored Architecture
App
├── Dashboard (listens to EventBus)
├── UserPanel
│ └── UserList
│ └── UserItem
│ └── UserActions (emits to EventBus)
└── Sidebar (listens to EventBus)
Components communicate via EventBus, no prop drilling.
Results
Before:
- Props through 5 levels
- Events through 5 levels
- Tightly coupled components
- Hard to test
After:
- Direct communication via EventBus
- Loosely coupled components
- Easy to test
- Easy to add features
Lessons Learned
- Start with props/events - Simplest pattern
- Use event bus sparingly - Only when needed
- Consider Vuex early - For complex state
- Always clean up - Prevent memory leaks
- Document events - What events does component emit?
Conclusion
Component communication is fundamental to Vue apps. Master these patterns and your code will be clean and maintainable.
Key takeaways:
- Props down, events up (default)
- Event bus for sibling communication
- Always validate props
- Clean up event listeners
- Use Vuex for complex state
Good component communication makes the difference between a maintainable app and a mess.