Sam Thorogood

Event-Driven JavaScript Development

Every JS developer has written code to handle "click" listeners—press a button, tap a link—and you'll do something interesting. Events are core to the way we write for the web and use the DOM. 👆

But what if I were to tell you that you should and could be using event-driven development for yourself—not even in the DOM? 🤔

To do that, we can now subclass EventTarget, giving your code access to fire its own events, and listen to those events using .addEventListener. This lets you properly encapsulate behavior—your abstraction can do some work, and other parts of your code can simply listen to when it's done.

Sorry, your browser doesn't support an inline demo.

Here's a simplified version of the demo's code:

class SomethingController extends EventTarget {
  async doSomething() {
    await someLongTask();
    this.dispatchEvent(new Event('amazing'));
  }
}
// now:
const c = new SomethingController();
c.addEventListener('amazing', () => ...);

Great! 🎉

Of course, I could have created the cute effect any way I liked—I didn't need events. But technically, the progress bar / button is totally separate from the effect—it's only connected with an event.

Hopefully the tl;dr code snippet is useful enough. But read on to find out:

  1. Why this is better than .on or your own DIY event system
  2. When to dispatch an Event, a CustomEvent or a subclass of either
  3. How to make tell TypeScript about your new events, e.g., allow you to autocomplete in VSCode and have type-safety in the compiler
  4. How to use a general-purpose object, which emits events, with {P}react

First, though! A bit of background. 🏔️

Background On Events

Elements on the page can emit events, such as a "click" event I mentioned above.

Depending on how the event is dispatched by the browser, it might be cancelable, and it may bubble/capture in interesting ways. This is just an accepted standard. For example:

I think that the average web developer has a vague sense of these rules: e.g., when you click something, it'll generate an event which bubbles and fires at all places "up" the chain (finishing on document.body). ⛓️

However! This post is about subclassing EventTarget directly, and wrapping up behavior outside of the DOM. So things like bubbling have no analogy here: a pure EventTarget isn't in the DOM, so that property is irrelevant.

And subclassing already happens in some web APIs you might be used to! These include, but are not limited to, WebSocket, the legacy XMLHttpRequest, and the objects in the Web Audio API—these all have events, but aren't physically on the page itself. ❌📃

In Detail

Why this way?

An obvious question is—why do events this way? If you've been around the block, including in Node.js, you'll probably be well-versed in APIs that provide methods like .on('foo', () => ...), or .addListener(() => ...—really, whatever custom way to add events.

Yes, it's a little verbose, but compatibility / standardization is really the crux of it. The EventTarget and addEventListener combination is standard and built-in, so your users know exactly how they'll work and what they'll get—it's like any other object which emits events.

class MyObject extends EventTarget {
  constructor() {
    doSetupWork().then(() => this.dispatchEvent(new Event('ready')));
  }
}
const myObject = new MyObject();

myObject.addEventListener('ready', () => {
  ...
});

Also, you can use AbortSignal to make your use of .addEventListener more streamlined! Check out AbortController is your friend.

What type of Event to dispatch

To fire an event, you have to create an instance of Event. In the example at the top of the page, I've written a line like:

const e = new Event('something_done');
this.dispatchEvent(e);

And this is absolutely fine if your event is just a name with no attached data. Great! 👍

But if you want to attach some data to the event, you'll need to take a different approach. We have two ways to add something here. The first is to use CustomEvent, which allows you to add anything on its .detail property:

const detail = 123;  // literally anything can go here
const e = new CustomEvent('something_done', { detail });
this.dispatchEvent(e);

// later
foo.addEventListener('something_done', (e) => {
  console.info(e.detail);  // "123"
});

The second is to subclass Event and define your own properties:

class SomethingDoneEvent extends Event {
  constructor(value) {
    super('something_done');
    this.value = value;
  }
}

// use like this
this.dispatchEvent(new SomethingDoneEvent(123));

// later
foo.addEventListener('something_done', (e) => {
  console.info(e.value);  // "123"
});

So, what should you do?

And, while it's definitely advanced, imagine a subclass of Event that has some additional functionality baked-in: maybe it acts like a stream between dispatcher and listener, or lets you call some function on it at a later point in time. While we're sending it around as an Event, it's still just a regular class.

And again, this is all possible with an arbitrary .details object, but gets awkward real fast. 🙅

Type Safety

When we use VSCode to interact with subclasses of EventTarget, we want to be able to type and see our options just appear. You probably also want typesafety if you compile with tsc, but honestly, the biggest win for me is VSCode not giving me a red squiggly line or having to cast to any. 😊

VSCode will autocomplete events and their event types

This doesn't happen for free just because WebSocket dispatches events. If you create a new object, like SomethingController, we have to actually tell TypeScript about the events it might dispatch.

But… this space is a mess. If we look at WebSocket in the TypeScript source code, it's ugly: 🤢

// NOTE: You don't have to do this! Keep reading!

interface WebSocket extends EventTarget {
  addEventListener<K extends keyof WebSocketEventMap>(type: K, listener: (this: WebSocket, ev: WebSocketEventMap[K]) => any, options?: boolean | AddEventListenerOptions): void;
  addEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions): void;
  removeEventListener<K extends keyof WebSocketEventMap>(type: K, listener: (this: WebSocket, ev: WebSocketEventMap[K]) => any, options?: boolean | EventListenerOptions): void;
  removeEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | EventListenerOptions): void;
}

interface WebSocketEventMap {
  "close": CloseEvent;
  "error": Event;
  "message": MessageEvent;
  "open": Event;
}

TypeScript struggles with this kind of inheritance. So, the solution used in standard library is to include this incredibly verbose block every time, which has to itself know about events that could have been fired on the "parent", too. Not fun.

However! I'm here to make it easy. 🧑🏻‍🎤

Solution

I've built a type that provides a way to extend EventTarget (or things that already extend it, like HTMLElement or WebSocket, but that's not really the point of this post) with arbitrary new events, making them all typesafe. You can use it like this:

import type { AddEvents } from 'typed-event-types';

interface ObjectEventMap {
  "whatever": WhateverEvent;
  "ready": Event;
}

class ObjectThatHasEvents extends (
  EventTarget as AddEvents<typeof EventTarget, ObjectEventMap>
) {
  // your class code goes here
}

const o = new ObjectThatHasEvents();
o.addEventListener('whatever', (e) => {
  // hooray! This will autocomplete and `e` will be of type `WhateverEvent`.
});

It's a little verbose (and I'd happily accept PRs to make it better). But it's definitely nicer than having to manually plumb in new addEventListener calls.

Go use it in your projects. And note! This is just adding types—there's no runtime cost to this—we're just hinting to TypeScript that these events exist.

Use EventTarget with {P}react Hooks

If you're a developer who only operates in {P}react-land, you might be saying: this is great, but how do I cause a hook or render to 'trigger'? Can I still use {P}react idioms here?

Well, the answer is—you can, and you can't. Once you're writing classes to wrap up functionality, you are well out of {P}react-land. But that can be a useful construct as now you're not trying to "fit" into a framework's box.

But effecting state? That's definitely possible, although has a bit of boilerplate. Let's say our SomethingController changes some value on itself, and fires the "whatever" event when that happens. You can provide a hook like this:

const useSomethingValue = (c: SomethingController) => {
  const [value, setValue] = useState(() => c.value);

  useEffect(() => {
    const handler = () => setValue(c.value);
    c.addEventListener('whatever', handler);
    return () => c.removeEventListener('whatever', handler);
  }, [c]);

  return value;
};

And now your users can interface with SomethingController, and have a hook that provides a value that's always up-to-date. 🗓️

An Extension On Events

There's some other interesting facts about events that didn't fit elsewhere.

So, if you have several "foo" listeners triggered by a user action, they'll each recieve the same instance of the event you passed to .dispatchEvent(...). This is not overly important except if you start passing data around, either with the CustomEvent instance, or your custom subclass—if your handler mucks with it, another listener will see the change. (But who knows—maybe this is a feature to you, not a bug. 🪲)

When you call .dispatchEvent(...), all registered listeners are invoked before the next line of your code runs. In fact, if any event listener throws an exception, that'll stop execution of all others.

I have two thoughts on this. I actually think that this behavior is fine, but I think that events that occur after long-running tasks can be nicely wrapped up in a microtask. This looks something like:

class Foo extends EventTarget {
  async longTask() {
    await longThing();
    queueMicrotask(() => {
      this.dispatchEvent(new Event('long_task_done'));
    });
    // maybe something else happens here
  }
}

In this way, your Foo won't experience a crash itself—it'll just show up as a global error.

This is somewhat controversial—lots of documentation says that window.onerror (in your browser) and "uncaughtException" (in Node) are definitely only meant as last-resorts, and you shouldn't be actively trying to trigger them.

But your event listeners that crash on when triggered from native code, e.g., event handlers for WebSocket or any DOM element- well, they already cause this kind of error.

So of course, you should attempt to write event handlers in a way that avoids crashes. (If you're really worried, try wrapping the body of your handler in a try/catch block.)

Perhaps counter-intuitively, my point above absolves your sins in writing async event handlers. I say this a bit facetiously. I've previously criticized event handlers that are async: "it's not async, events are fired synchronously, please fix that code". 😤

But! Event handlers can crash in uncontrollable ways anyway. You can't control this. So making them async is not any more dangerous. They might still crash! Just… later. 🤣

The most important take-away from async event handlers to remember that your async event handler will not block other handlers. For example, this code:

class Foo extends EventTarget {
  testEvent() {
    console.info('before');
    this.dispatchEvent(new Event('testEvent'));
    console.info('after');
  }
}

const f = new Foo();

f.addEventListener('testEvent', async () => {
  console.info('async handler immediate');
  await new Promise(resolve => setTimeout(resolve, 0));
  // this will happen after everything else is done:
  // it doesn't block "testEvent" from completing
  console.info('async handler after');
});

f.addEventListener('testEvent', () => {
  console.info('non-async handler');
});

f.testEvent();

…will output:

before
async handler immediate
non-async handler
after
async handler after

Phew. Rant over!

Why Event-Driven JS?

Well… these objects look fine, but maybe you're still asking why writing a controller, or object, helps you at all. 🤔

Using events falls into a category of—there are lots of traditional software engineering patterns that I think on the web tend to be forgotten in favor of trying to fit awkwardly into a framework's box. By looking at how the platform itself provides abstractions, like WebSocket, and building them ourselves, we can build better software—you should be hiding complex behavior inside primitive classes.

Personally though?

I am a somewhat senior generalist software engineer. Yes, I write about the web, my role is the CTO of an energy SaaS that has a single, large React app. But software engineering, more than a specific technology, is about building complex systems and making them work together.

And how do you do that? With good abstractions. 💡

What that means is—if you find yourself trying to mix two different types of state, or hitting the limit in your current development "context", whether that's a… {P}react component, callback, whatever, maybe you should consider pulling out the thing you're trying to do into its own object.

And then, what this blog post is all about, use events as one of the many ways you can interact with that object. As well as, potentially, writing a TypeScript interface for that object, so that the how is separated out from the what.

As an extension—this post isn't about interfaces, after all—the standard answer to why you need an interface is that you have many classes doing the same thing. That's not actually the best reason for them, in my opinion.

For me, an interface or any abstraction forces you to separate out concerns—this lets you think of put some functionality away in a nice box 📦, and compartmentalize it. Your team, or even your future self, will thank you! That complex functionality is now hidden behind just a few, clear methods, rather than leaking all over the rest of your code. 🫠

Done

This has been a long post. Like everything I write, I hope it's been useful! 😬

For me, researching the TypeScript definitions and finding a solution to the 'generic events' problem has been invaluable, and I hope you'll consider depending on the "typed-event-types" package—again, it's purely types, so has no impact on your build size—to embrace event-driven JS.

See you next time! Follow me on Twitter. 🐦