Event Bus with Vue 3 and TypeScript

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

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:

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:

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:

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 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 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:

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:

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):

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:

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:

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.

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:

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:

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:

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:

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:

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:

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:

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:

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:

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 Symbols, but with TypeScritp it shouldn't be needed, there are no performance advantages to using Symbols 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.