How to Build a Vue 3 Event Bus Without External Libraries
The recommended way to implement a Vue 3 event bus without third-party dependencies is to create a lightweight emitter class and distribute it using Vue’s built-in provide and inject APIs, which are part of the core runtime in packages/runtime-core/src/apiInject.ts.
Vue 3 eliminates the complexity of external event libraries by shipping powerful dependency injection primitives directly in the framework core. By leveraging the native provide and inject system implemented in the vuejs/core repository, you can construct a type-safe, reactive-free vue 3 event bus that enables decoupled communication between any components without introducing external dependencies or global state pollution.
Why Native Provide/Inject Beats Third-Party Event Buses
Third-party event buses often carry unnecessary overhead for simple cross-component messaging. Vue 3’s provide and inject APIs, located in packages/runtime-core/src/apiInject.ts (lines 10‑34 for provide and lines 36‑85 for inject), offer a dependency injection pattern that integrates seamlessly with the component lifecycle. This approach avoids the reactivity overhead of Vuex or Pinia while providing better type safety and tree-shaking than legacy event emitter libraries.
Creating the Event Bus Class
Start by implementing a lightweight emitter using a Map to store event listeners. This class exposes on, off, and emit methods for full pub/sub functionality.
// src/event-bus.ts
type EventHandler = (...args: any[]) => void
export class EventBus {
private listeners = new Map<string, Set<EventHandler>>()
on(event: string, handler: EventHandler): void {
const set = this.listeners.get(event) ?? new Set()
set.add(handler)
this.listeners.set(event, set)
}
off(event: string, handler: EventHandler): void {
const set = this.listeners.get(event)
if (set) {
set.delete(handler)
if (set.size === 0) this.listeners.delete(event)
}
}
emit(event: string, ...payload: any[]): void {
const set = this.listeners.get(event)
if (set) {
// Copy to avoid mutation while iterating
Array.from(set).forEach(fn => fn(...payload))
}
}
}
This implementation uses a Set for each event type to ensure O(1) addition and removal of listeners, with automatic cleanup when no handlers remain for a specific event.
Providing the Bus at the Application Root
Register a single shared instance at application initialization using the global app.provide() method. According to the source in packages/runtime-core/src/apiCreateApp.ts (lines 83‑87), this attaches the provider to the app context’s provides object (defined at lines 77‑78), making it available throughout the component tree.
// src/main.ts
import { createApp } from 'vue'
import App from './App.vue'
import { EventBus } from './event-bus'
const app = createApp(App)
// Create singleton instance
const eventBus = new EventBus()
// Provide under an injection key
app.provide('eventBus', eventBus)
app.mount('#app')
Using app.provide() ensures the event bus is available globally while maintaining proper injection scoping for server-side rendering and custom element scenarios.
Injecting and Using the Bus in Components
Components consume the bus using the inject() function. Always pair event subscriptions with cleanup logic in onBeforeUnmount to prevent memory leaks.
Publisher Component:
<!-- src/components/Publisher.vue -->
<script setup lang="ts">
import { inject } from 'vue'
import type { EventBus } from '@/event-bus'
const bus = inject<EventBus>('eventBus')!
function sendMessage() {
bus.emit('chat', 'Hello from Publisher')
}
</script>
<template>
<button @click="sendMessage">Send Message</button>
</template>
Subscriber Component:
<!-- src/components/Subscriber.vue -->
<script setup lang="ts">
import { inject, onMounted, onBeforeUnmount } from 'vue'
import type { EventBus } from '@/event-bus'
const bus = inject<EventBus>('eventBus')!
function handleChat(msg: string) {
console.log('Received:', msg)
}
onMounted(() => bus.on('chat', handleChat))
onBeforeUnmount(() => bus.off('chat', handleChat))
</template>
<template>
<p>Check console for messages.</p>
</template>
The explicit cleanup in onBeforeUnmount is critical because the event bus operates outside Vue’s component reactivity system, meaning destroyed components will not automatically stop listening unless you manually call off().
Implementing Type-Safe Injection Keys
For production applications, use Symbol-based injection keys to prevent naming collisions and enable stronger TypeScript inference.
// src/keys.ts
import type { InjectionKey } from 'vue'
import type { EventBus } from './event-bus'
export const EVENT_BUS: InjectionKey<EventBus> = Symbol('event-bus')
// main.ts
import { EVENT_BUS } from '@/keys'
app.provide(EVENT_BUS, eventBus)
<!-- Component -->
<script setup lang="ts">
import { inject } from 'vue'
import { EVENT_BUS } from '@/keys'
import type { EventBus } from '@/event-bus'
const bus = inject(EVENT_BUS)!
</script>
This pattern leverages Vue 3’s InjectionKey utility to enforce compile-time type checking across your application.
How Provide/Inject Works in the Vue 3 Core
Understanding the underlying implementation helps debug edge cases in complex component hierarchies.
provideimplementation: The function inpackages/runtime-core/src/apiInject.ts(lines 10‑34) stores values on the current component instance’sprovidesobject, creating a prototype chain lookup mechanism for nested providers.injectresolution: The logic in lines 36‑85 of the same file traverses up the parent component chain to locate the nearest provider, falling back to default values or the app context if specified.- Component
$emit: While the instance method exposed inpackages/runtime-core/src/componentPublicInstance.ts(line 15) handles parent-child communication via$emit: EmitFn<E>, the provide/inject event bus operates independently of the component hierarchy, enabling sibling-to-sibling communication. - App context storage: The root
providesobject referenced inpackages/runtime-core/src/apiCreateApp.ts(lines 77‑78) serves as the fallback reservoir whenapp.provide()is used, ensuring global availability.
These core utilities guarantee that your custom event bus behaves consistently across Vue 3’s reactivity system, SSR environments, and custom element builds.
Summary
- Use
provideandinject: The native Vue 3 APIs inpackages/runtime-core/src/apiInject.tsprovide a zero-dependency foundation for event bus patterns. - Implement a class-based emitter: A simple TypeScript class using
Map<string, Set<function>>offers better performance and type safety than plain objects. - Register at app level: Use
app.provide()(implemented inpackages/runtime-core/src/apiCreateApp.ts) to create a singleton available throughout the component tree. - Always clean up listeners: Manually remove handlers in
onBeforeUnmountto prevent memory leaks, as the bus operates outside Vue’s automatic reactivity tracking. - Prefer Symbol keys: Use
InjectionKeysymbols for type-safe, collision-proof dependency injection in production codebases.
Frequently Asked Questions
Is a custom event bus better than using global properties in Vue 3?
Global properties (app.config.globalProperties) pollute the component instance namespace and lack TypeScript inference. The provide/inject pattern keeps dependencies explicit, supports strong typing via InjectionKey, and integrates with Vue’s component hierarchy resolution as implemented in packages/runtime-core/src/apiInject.ts.
How do I prevent memory leaks when using a Vue 3 event bus?
Always pair every bus.on() call with a corresponding bus.off() inside onBeforeUnmount. Because the event bus stores references to callback functions in a Map, components will not be garbage collected if listeners remain registered after the component is destroyed. The example in Subscriber.vue demonstrates this essential cleanup pattern.
Can I make the event bus type-safe for specific event names and payloads?
Yes. Extend the EventBus class with generic type parameters that map event names to payload tuples, or use a typed InjectionKey when injecting the bus. This allows TypeScript to validate event names and payload types at compile time, catching errors before they reach runtime.
Does this approach work with both the Options API and Composition API?
Absolutely. While the examples use <script setup> (Composition API), you can inject the bus in Options API components using the inject property: inject: ['eventBus'], or by calling this.$eventBus if you assign it to globalProperties (though provide/inject remains the cleaner architectural choice).
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →