Let’s Create a Lightweight Native Event Bus in JavaScript

Avatar of Carter Li
Carter Li on

📣 Freelancers, Developers, and Part-Time Agency Owners: Kickstart Your Own Digital Agency with UACADEMY Launch by UGURUS 📣

An event bus is a design pattern (and while we’ll be talking about JavaScript here, it’s a design pattern in any language) that can be used to simplify communications between different components. It can also be thought of as publish/subscribe or pubsub.

The idea is that components can listen to the event bus to know when to do the things they do. For example, a “tab panel” component might listen for events telling it to change the active tab. Sure, that might happen from a click on one of the tabs, and thus handled entirely within that component. But with an event bus, some other elements could tell the tab to change. Imagine a form submission which causes an error that the user needs to be alerted to within a specific tab, so the form sends a message to the event bus telling the tabs component to change the active tab to the one with the error. That’s what it looks like aboard an event bus.

Pseudo-code for that situation would be like…

// Tab Component
Tabs.changeTab = id => {
  // DOM work to change the active tab.
}
MyEventBus.subscribe("change-tab", Tabs.changeTab(id));

// Some other component...
// something happens, then:
MyEventBus.publish("change-tab", 2);  

Do you need a JavaScript library to this? (Trick question: you never need a JavaScript library). Well, there are lots of options out there:

Also, check out Mitt which is a library that’s only 200 bytes gzipped. There is something about this simple pattern that inspires people to tackle it themselves in the most succincet way possible.

Let’s do that ourselves! We’ll use no third-party library at all and leverage an event listening system that is already built into JavaScript with the addEventListener we all know and love.

First, a little context

The addEventListener API in JavaScript is a member function of the EventTarget class. The reason we can bind a click event to a button is because the prototype interface of <button> (HTMLButtonElement) inherits from EventTarget indirectly.

Source: MDN Web Docs

Different from most other DOM interfaces, EventTarget can be created directly using the new keyword. It is supported in all modern browsers, but only fairly recently. As we can see in the screenshot above, Node inherits EventTarget, thus all DOM nodes have method addEventListener.

Here’s the trick

I’m suggesting an extremely lightweight Node type to act as our event-listening bus: an HTML comment (<!-- comment -->).

To a browser rendering engine, HTML comments are just notes in the code that have no functionality other than descriptive text for developers. But since comments are still written in HTML, they end up in the DOM as real nodes and have their own prototype interface—Comment—which inherits Node.

The Comment class can be created from new directly like EventTarget can:

const myEventBus = new Comment('my-event-bus');

We could also use the ancient, but widely-supported document.createComment API. It requires a data parameter, which is the content of the comment. It can even be an empty string:

const myEventBus = document.createComment('my-event-bus');

Now we can emit events using dispatchEvent, which accepts an Event Object. To pass user-defined event data, use CustomEvent, where the detail field can be used to contain any data.

myEventBus.dispatchEvent(
  new CustomEvent('event-name', { 
    detail: 'event-data'
  })
);

Internet Explorer 9-11 supports CustomEvent, but none of the versions support new CustomEvent. It’s complex to simulate it using document.createEvent, so if IE support is important to you, there’s a way to polyfill it.

Now we can bind event listeners:

myEventBus.addEventListener('event-name', ({ detail }) => {
  console.log(detail); // => event-data
});

If an event intends to be triggered only once, we may use { once: true } for one-time binding. Other options won’t fit here. To remove event listeners, we can use the native removeEventListener.

Debugging

The number of events bound to single event bus can be huge. There also can be memory leaks if you forget to remove them. What if we want to know how many events are bound to myEventBus?

myEventBus is a DOM node, so it can be inspected by DevTools in the browser. From there, we can find the events in the Elements → Event Listeners tab. Be sure to uncheck “Ancestors” to hide events bound on document and window.

An example

One drawback is that the syntax of EventTarget is slightly verbose. We can write a simple wrapper for it. Here is a demo in TypeScript below:

class EventBus<DetailType = any> {
  private eventTarget: EventTarget;
  constructor(description = '') { this.eventTarget = document.appendChild(document.createComment(description)); }
  on(type: string, listener: (event: CustomEvent<DetailType>) => void) { this.eventTarget.addEventListener(type, listener); }
  once(type: string, listener: (event: CustomEvent<DetailType>) => void) { this.eventTarget.addEventListener(type, listener, { once: true }); }
  off(type: string, listener: (event: CustomEvent<DetailType>) => void) { this.eventTarget.removeEventListener(type, listener); }
  emit(type: string, detail?: DetailType) { return this.eventTarget.dispatchEvent(new CustomEvent(type, { detail })); }
}
    
// Usage
const myEventBus = new EventBus<string>('my-event-bus');
myEventBus.on('event-name', ({ detail }) => {
  console.log(detail);
});

myEventBus.once('event-name', ({ detail }) => {
  console.log(detail);
});

myEventBus.emit('event-name', 'Hello'); // => Hello Hello
myEventBus.emit('event-name', 'World'); // => World

The following demo provides the compiled JavaScript.


And there we have it! We just created a dependency-free event-listening bus where one component can inform another component of changes to trigger an action. It doesn’t take a full library to do this sort of stuff, and the possibilities it opens up are pretty endless.