Peter Krieg's blog

Dispatching Custom Events with Typescript


Any web application needs to respond to events. At Logikcull, I wanted to share the evolution of us handling these in a real-world, large application with a mixture of legacy and newer code.

Backbone

All of our legacy codebase (~27% of JS/TS codebase at this point by # lines of code) is written in backbone.js and marionette. This code relies heavily on events between different places in the app, utilizing App.trigger, App.execute, etc. The organization ended up being a bit of spaghetti code for us - it ends up being a web of tracking the flow of multiple events, and it’s difficult to tell when events may be unused (stale code).

Declarative React

We write all new code in react now, and instead of handling events everywhere, we prefer keeping state as local as possible (by default simply with useState). This is the magic of react - you don’t have to worry about notifying components when data changes with events (like you might manually wire up with backbone). If state changes, the component re-renders! This works for tons of use-cases, but what about for components that are universes apart in the web application? Like something on a top navbar that might communicate to a sidebar? We can’t “lift” state up here - or else it would end up as high as global state, like in redux or something. It would be better for cases like this if we could just have some custom events.

Redux Toolkit Event Listeners

Redux toolkit documents a possible approach of subscribing to an action within a component. The sample code, copied below, looks something like:

useEffect(() => {
  const unsubscribe = dispatch(
    addListener({
      actionCreator: todoAdded,
      effect: (action, listenerApi) => {
        // do some useful logic here
      },
    })
  );
  return unsubscribe;
}, []);

This has the benefit of being able to be observed in the redux browser extension for debugging. But what if the event doesn’t actually change state, but just passes along data? The reducer would just be an empty function block doing nothing. Not to mention that this approach is a bit verbose.

Custom Events

It turns out we can define custom javascript events to handle this case! It’s simpler, plus built into the web spec which is always a bonus. Within a react environment, it’s super helpful to have a hook like useEventListener. I won’t explain that hook in this blog post, but it automatically handles cleanup of event listeners for you and is super helpful.

As shown in the use-hooks link above, you can then define types for your custom events by extending the WindowEventMap.

declare global {
  interface CustomEventMap {
    showLeftNav: CustomEvent;
    setAudioStartTime: CustomEvent<number>;
  }
}

declare global {
  interface WindowEventMap extends CustomEventMap {}
}

This means that both of our custom events are now automatically picked up by useEventListener, and the data passed in will be picked up by event.detail!

useEventListener("setAudioStartTime", (event) => {
  const audioTime = event.detail; // typed as number
});

But how do we type the dispatching of the event? Currently, to dispatch an event you could do something like:

window.dispatchEvent(new CustomEvent("setAudioStartTime", { detail: 20 }));

But the built-in types for CustomEvent just have the type as a string, which wouldn’t protect against typos. In fact, we could dispatch any bogus event and typescript wouldn’t complain at all!

window.dispatchEvent(new CustomEvent("blah", { someWrongProperty: "oops" }));

To solve this and have type-safety I decided to write a helper function.

dispatchCustomEvent Helper

The goal is to have a global interface CustomEventMap where the helper can only dispatch custom events that exist on that interface. If the custom event has data required (like the setAudioStartTime which requires a number passed in from example above) then dispatchCustomEvent should be smart enough to require that 2nd argument passed in.

I’m going to show the final code first and then explain how it works:

// for purposes of typing `dispatchCustomEvent` below, we filter out event map
// to only include entries that include data defined in CustomEvent generic
type CustomEventsWithData = {
  [K in keyof CustomEventMap as CustomEventMap[K] extends CustomEvent<
    infer DetailData
  >
    ? IfAny<DetailData, never, K>
    : never]: CustomEventMap[K] extends CustomEvent<infer DetailData>
    ? DetailData
    : never;
};

export function dispatchCustomEvent(
  eventName: Exclude<keyof CustomEventMap, keyof CustomEventsWithData>
);
export function dispatchCustomEvent<
  EventName extends keyof CustomEventsWithData
>(eventName: EventName, eventData: CustomEventsWithData[EventName]);
export function dispatchCustomEvent(eventName, eventData?) {
  window.dispatchEvent(new CustomEvent(eventName, { detail: eventData }));
}

There’s probably a more elegant solution to this, but this works perfectly for what we actually wanted to accomplish. I first differentiate which events actually require data passed in, which is the CustomEventsWithData. For our 2 events, that map would then look like { setAudioStartTime: number }. I then use function overloading to handle the 2 cases of:

  1. If the event type passed in isn’t one including data (Exclude<keyof CustomEventMap, keyof CustomEventsWithData>) then only one argument is required for the function (just the event name).
  2. If the event type is one including data, then the 2nd argument must be event data passed in.

It might make more sense to first show what events would error out, with a screenshot straight from my editor:

The first error is because setAudioStartTime requires a 2nd argument of the start time! The 2nd error is because the argument passed in isn’t correct - it just needs a number instead of an object. And the last error is because showLeftNav doesn’t require data passed in. Both of these can be simply fixed to the following:

One other note - the isAny helper type from code above was imported from type-fest, a type utility library. I find this library helpful in any typescript project, but you could also just copy the helper yourself. It just makes the code a bit cleaner without more layers of nested ternaries.

So now we have a type-safe helper utility to dispatch events! This will help keep the code robust and help catch bugs by preventing incorrectly fired (or consumed) events.

Colocation

One other note I wanted to add was why I chose the extra step of:

declare global {
  interface CustomEventMap {
    showLeftNav: CustomEvent;
    setAudioStartTime: CustomEvent<number>;
  }
}

declare global {
  interface WindowEventMap extends CustomEventMap {}
}

You could technically just have all of your custom events in one file somewhere and then implement like:

declare global {
  interface WindowEventMap {
    showLeftNav: CustomEvent;
    setAudioStartTime: CustomEvent<number>;
  }
}

But In my opinion it’s easier if you can define events in multiple files, wherever the event is used, instead of one big list of events. I think this follows the best practice of colocation - which I linked to the popular Kent C. Dodds blog.

Do you even need this?

It’s worth mentioning that many apps wouldn’t even need to handle custom events like this at all. Firing too many custom events could lead to the same confusion as with Backbone - where events can be hard to trace down sometimes. Redux doesn’t exactly recommend this approach - copied from the link:

While this pattern is possible, we do not necessarily recommend doing this! The React and Redux communities have always tried to emphasize basing behavior on state as much as possible. Having React components directly tie into the Redux action dispatch pipeline could potentially lead to codebases that are more difficult to maintain.

Currently at Logikcull, we have around 10 custom events used, which I feel are all warranted. The 2 used as examples in this blog post are both real events taken from Logikcull’s codebase. The showLeftNav event (also had a hideLeftNav) was useful for allowing far different parts of the app to trigger the nav menu to expand/close. The setAudioStartTime was useful for allowing a user to click a section of a transcript and updating the play-time in the audio player. Both of these specific cases seemed best handled as global events for the following reasons:

As always, there are multiple solutions for any given problem. Dispatching custom events is just another tool in the toolbox that can be used.