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.
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:
- Why this is better than
.on
or your own DIY event system - When to dispatch an
Event
, aCustomEvent
or a subclass of either - How to make tell TypeScript about your new events, e.g., allow you to autocomplete in VSCode and have type-safety in the compiler
- 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:
- "click" events will typically bubble and be cancelable (because they're often on
<a href>
or some other interactive element, and you might want to disable that behavior) - whereas "change" isn't cancelable, becuase it reflects a change on an
<input>
that's already happened
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?
- If an event doesn't need extra data, keep using
Event
with a custom name—this is totally fine - But if you need to pass data around, I'd suggest subclassing: it's a bit more up-front work, but it's easier to understand the shape of the event: you're not awkwardly trying to remember what
detail
is supposed to be (although read on—types can help too) 😕
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
. 😊
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.
- When an event is dispatched by a browser, it's a single object. Naïvely, you might assume the object is cloned and sent anew—sadly, no.
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. 🪲)
- Events are delivered synchronously. This is actually largely unlike event loops in many native platforms.
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.)
- Events handlers can be async. They technically shouldn't be—they're called synchronously, so… hear me out.
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. 🐦