# How to Build a Vue 3 Event Bus Without External Libraries

> Learn how to build a Vue 3 event bus without external libraries. Discover the recommended approach using Vue's provide and inject APIs for efficient cross-component communication.

- Repository: [Vue/core](https://github.com/vuejs/core)
- Tags: how-to-guide
- Published: 2026-02-20

---

**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`](https://github.com/vuejs/core/blob/main/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`](https://github.com/vuejs/core/blob/main/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.

```typescript
// 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`](https://github.com/vuejs/core/blob/main/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.

```typescript
// 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:**

```vue
<!-- 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:**

```vue
<!-- 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.

```typescript
// src/keys.ts
import type { InjectionKey } from 'vue'
import type { EventBus } from './event-bus'

export const EVENT_BUS: InjectionKey<EventBus> = Symbol('event-bus')

```

```typescript
// main.ts
import { EVENT_BUS } from '@/keys'
app.provide(EVENT_BUS, eventBus)

```

```vue
<!-- 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.

- **`provide` implementation**: The function in [`packages/runtime-core/src/apiInject.ts`](https://github.com/vuejs/core/blob/main/packages/runtime-core/src/apiInject.ts) (lines 10‑34) stores values on the current component instance’s `provides` object, creating a prototype chain lookup mechanism for nested providers.
- **`inject` resolution**: 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 in [`packages/runtime-core/src/componentPublicInstance.ts`](https://github.com/vuejs/core/blob/main/packages/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 `provides` object referenced in [`packages/runtime-core/src/apiCreateApp.ts`](https://github.com/vuejs/core/blob/main/packages/runtime-core/src/apiCreateApp.ts) (lines 77‑78) serves as the fallback reservoir when `app.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 `provide` and `inject`**: The native Vue 3 APIs in [`packages/runtime-core/src/apiInject.ts`](https://github.com/vuejs/core/blob/main/packages/runtime-core/src/apiInject.ts) provide 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 in [`packages/runtime-core/src/apiCreateApp.ts`](https://github.com/vuejs/core/blob/main/packages/runtime-core/src/apiCreateApp.ts)) to create a singleton available throughout the component tree.
- **Always clean up listeners**: Manually remove handlers in `onBeforeUnmount` to prevent memory leaks, as the bus operates outside Vue’s automatic reactivity tracking.
- **Prefer Symbol keys**: Use `InjectionKey` symbols 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`](https://github.com/vuejs/core/blob/main/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`](https://github.com/vuejs/core/blob/main/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).