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

  1. Props down, events up - Default pattern
  2. Validate props - Always use prop validation
  3. Event bus for siblings - When props/events don’t work
  4. Clean up listeners - Always $off in beforeDestroy
  5. 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

  1. Start with props/events - Simplest pattern
  2. Use event bus sparingly - Only when needed
  3. Consider Vuex early - For complex state
  4. Always clean up - Prevent memory leaks
  5. 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:

  1. Props down, events up (default)
  2. Event bus for sibling communication
  3. Always validate props
  4. Clean up event listeners
  5. Use Vuex for complex state

Good component communication makes the difference between a maintainable app and a mess.