Event Bus with Vue 3 and TypeScript
Tags: vue, typescript
Published: 2023-12-04 19:41:53

Vue 3 removed the beloved Event Bus used in Vue 2, but nothing is lost! Vue's internal Event Bus became obsolete and unnecessary in the Vue itself. It was quite useful, especially before we got `provide/inject`.

It allowed communication between components, even siblings, without using a global store (which also isn't a good place to share information about events) or even knowing (having the reference to instance) the receiver/target.

## What is an Event Bus (Its simplified version)?

In simple words, it's a bus on which we put some events, inform that something happened, and some others receive them and handle them. Its purpose is not to store data, like global storage (like Pinia), but to broadcast events, just that: like get on a scene and say something. To notify subscribers (those among the audience, who are interested) that something just happened. Every event has its name/title/type (depending on how the creator of the Event Bus called it), and body (this might be optional, also it depends on the implementation of Event Bus).

The entity that sends an event to the Event Bus we call a **publisher**, while the entity that receives it a **subscriber**. Subscriber signs to the Event Bus to listen to events of such type (or name, title...). The Event Bus object we can call **emitter**. Its instance is used to both send events and subscribe to listen to them.

From the publisher's point of view: you send an event and you don't care what happens to it. You did your job.

Subscriber listens to the events of chosen types and handles them. It doesn't matter where the event comes from, just that the event happened and that's all it should care about.

Emitter does the job of sending the events to the subscribers who are interested in those events, so you don't have to check the type of event manually.

![An event of type `foo` is sent to two subscribers that listens to that types of event](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/zdohpiew1jst1mkwckoj.png)

The diagram above shows that the message of type `foo` is sent to the subscribers that listen to that kind of event, and the third subscriber that listens only to `bar` and `baz` won't receive that event.

Of course, it was the simplest implementation of the event bus possible, there are more complex implementations that require validation, verification of events, caching them, queuing, notifying publishers about what happened, etc.

## A bit of history, how did it work in Vue 2?

Skip this part, if you don't care.

In Vue 2 every component was an event bus and had methods `$on`, `$off`, `$once` and `$emit`, now we have only the last one, and events are solved more natively.

The easiest way was to use the `$root` component (the main component, created via `new Vue`) because it was easily accessible through `this.$root`. Another way, was to use an empty `Vue` instance exported somewhere with a simple `export new Vue({})`.

Using it was rather... annoying:

```typescript
export const defineComponent({
  //
  methods: {
    onDataChange (args: any) {
      console.log(args)
    }
  },
  created () {
    this.$root.$on("data-change", this.onDataChange);
  },
  destroyed () {
    this.$root.$off("data-change", this.onDataChange);
  }
});
```

Each event we were listening to had to be released manually. But it was possible to deal with it using a mixin:

```typescript
export const WithEventBusMixin = {
  methods: {
    onEvent(key, cb) {
      this._releaseAtDestroy = this._eventsToRelease ?? [];
      this.$root.$on(key, cb);
      this._releaseAtDestroy.push(() => this.$root.$off(key, cb));
    },
  },
  destroyed() {
    this._releaseAtDestroy?.forEach(cb => cb());
  }
}
```

Then we could do this:

```typescript
export const defineComponent({
  //
  mixins: [WithEventBusMixin],
  methods: {
    onDataChange (args: any) {
      console.log(args)
    }
  },
  created () {
    this.onEvent("data-change", this.onDataChange);
  },
});
```

Much easier, but we are still lacking something... Typing! Sure, we could create multiple `WithEventBusMixin` variants to type `onEvent` arguments, but again, we would have to have a different `onEvent` method name for every mixin. Don't even make me write a dynamic mixin ever again.

## Now just use Mitt

[Mitt](https://github.com/developit/mitt) is a small (200 bytes) library that provides the same functionality. It doesn't have many updates, because it's simply perfect and there are not many things to improve there. It gets the job done. Don't be frightened, if in 2 years npm will say it's 2 years old. This library is just perfect with no bloatware. In the worst case, just copy-paste its code to your app.

In Mitt underneath there is a [map](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map) of an array of handlers for each type. It means it doesn't track actual subscribers, it just allows you to add a handler to a type of event and remove it. Every handler must be unsubscribed manually. Also, there is no 'publisher' per se, you can emit an event from any place you have access to the event bus instance (called Emitter).

We use it similarly to how we used event buses in Vue 2. We mount listeners with `on`, unmount with `off`, and emit with... `emit`. It's painlessly easy to use it, we first create a bus:

```typescript
import mitt from "mitt";
type EventBus = {
  foo: string;
}
export const eventBus = mitt<EventBus>();
```

We can import that object anywhere and send an event on it anytime with a simple:

```typescript
eventBus.emit("foo", "bar");
```

It will notify every listener who listens to the event with the type `"foo"`.

Then we can subscribe to these events it in components (or anywhere, those are simple JS objects, not associated with Vue's context):

```typescript
import { eventBus } from "src/utils/eventBus";

function onFoo (val: string) {
  console.log("Foo happened", val)
}
eventBus.on("foo", onFoo);
onUnmounted(() => eventBus.off("foo", onFoo));
```

Sure, the `type EventBus = {}` isn't required, but I strongly recommend typing every event bus. It will prevent any typos in types of events (Mitt uses 'type' for 'name' of the event). Here again, we have to manually unsubscribe from the event, otherwise, the `onFoo` will be called even after the component is unmounted and removed.

## Simple wrapper for mitt

It's nice to not have to remember to unsubscribe manually after each subscribe, so we just write a wrapper that does it for us:

```typescript
import { Emitter } from "mitt";

export function useMittEvent<
  T extends Record<string, unknown>,
  K extends keyof T
>(mitt: Emitter<T>, key: K, fn: (e: T[K]) => any) {
  mitt.on(key, fn);
  onUnmounted(() => mitt.off(key, fn));
}
```

This will help you keep the typing, by just providing the event bus instance. Then we use it:

```typescript
useMittEvent(eventBus, "foo", (val) => {
  //
});
```

It will subscribe to the event type just on the call and unsubscribe when the component is unmounted.

This might not be too useful when we want to listen to multiple events (too many lines, repeating), so we can write a more universal wrapper that handles multiple event types at once. We will pass the handlers through an object, subscribe to the events in the loop, and do the same to unsubscribe.

```typescript
type Handler<T = unknown> = (event: T) => void;
type EventHandlers<T extends Record<string, unknown>> = {
  [K in keyof T]: (event: T[K]) => void;
};

export function useMittEvents<T extends Record<string, unknown>>(
  mitt: Emitter<T>,
  handlers: EventHandlers<T>
) {
  for (const key of Object.keys(handlers)) {
    mitt.on(key, handlers[key]);
  }
  function cleanup() {
    for (const key of Object.keys(handlers)) {
      mitt.off(key, handlers[key]);
    }
  };
  onUnmounted(cleanup);
  return cleanup;
}
```

It returns the function to unmount the listeners if you want to do it manually for some reason.

TypeScript will have a few problems here: It won't check the type correctness directly on `on` and `off` methods, but it doesn't matter, since we got it covered in the generics. Now we can use this composable easily in our component:

```typescript
type EventBus = {
  foo: string;
  bar: number,
};
export const eventBus = mitt<EventBus>();
//
useMittEvents(eventBus, {
  foo(str) { // `str` is string
    console.log("valid", str);
  },
  bar(num) { // `num` is number
    console.log("valid", num);
  },
  baz(v) { // error!
    console.log("invalid", v);
  },
});
```

Or with arrow functions, to make it shorter in this particular case:

```typescript
useMittEvents(eventBus, {
  foo: (str) => console.log("valid", str),
  bar: (num) => console.log("valid", num),
  baz: (v) => console.log("invalid", v), // error
});
```

And that's it, we made a simple wrapper for mitt without installing any other heavy dependency.

### Dedicated listener

If passing `eventBus` the instance is too much, you can write a dedicated wrapper for a bus using the `eventBus` directly:

```typescript
export function useMyEvents(handlers: EventHandlers<EventBus>) {
  const keys = Object.keys(handlers) as Array<keyof EventBus>;
  for (const key of keys) {
    eventBus.on(key, handlers[key] as never);
  }
  const cleanup = () => {
    for (const key of keys) {
      eventBus.off(key, handlers[key] as never);
    }
  };
  onUnmounted(cleanup);
  return cleanup;
}
```

And yeah... here we have to do the dirty trick `as never`... Or write another wrapper:

```typescript
function wrapEventBus<T extends Record<string, unknown>>(
  mitt: Emitter<T>
) {
  return (ev: EventHandlers<T>) => useMittEvents(mitt, ev);
}

const useMyEvents = wrapEventBus(eventBus);
```

And use the same:

```typescript
useMyEvents({
  foo: (str) => console.log("valid", str),
  bar: (num) => console.log("valid", num),
  baz: (v) => console.log("invalid", v), // still error!
});
```

## Mitt within provide/inject

Sometimes you don't want a global event bus, just... local. Sometimes we want to separate contexts, like using the same component for multiple views, each one working separately, like multiple windows running withing an operating system.

Anyway, we would need to start by defining a Symbol for that:

```typescript
export const LocalEventBus: InjectionKey<Emitter<MyEvents>> 
  = Symbol("myEventBus");
```

It will be used for both `provide` and `inject`. Now we will need to create a local instance for the bus in the component and provide it:

```typescript
const localBus = mitt<MyEvents>();
provide(LocalEventBus, localBus);
```

It would be nice to wrap it in a composable. Then in child components, we can use a modified wrapper:

```typescript
export function useMyEvents(handlers: EventHandlers<EventBus>) {
  // get current context event bus
  const eventBus = inject(LocalEventBus);
  if (eventBus == null) // if not found
    throw new Error("No event bus found within this context");
  // it's fine, use the instance
  const keys = Object.keys(handlers) as Array<keyof EventBus>;
  for (const key of keys) {
    eventBus.on(key, handlers[key] as never);
  }
  // ... clean
}
```

If we'll use `useMyEvents` not within the context that provides this Event Bus, it will throw an error.

It would be also nice to make sure if the component that created the `localBus` cleaned it up upon its `onUnmounted`:

```typescript
onUnmounted(() => {
  localBus.all.clear()
});
```

It's a bit dirty way, but it will remove every listener within the mitt instance. `all` is the mentioned `Map` that contains all listeners, if we call `clear` method on it, it will do the job. There really is no magic underneath there, everything relies on that `Map`.

## Summary

While they took away a pretty useful feature with Vue 3, mitt, a really small library, fulfills that gap easily. Mitt also provides wildcard type `"*"`, but I've decided to not cover that here. Also, types could be `Symbol`s, but with TypeScritp it shouldn't be needed, there are no performance advantages to using `Symbol`s here.

Maybe Vue lost its useful feature, but it's nothing that couldn't be replaced. Event Buses are still useful and powerful tools on the front end to propagate events.