Web development often involves creating dynamic and interactive user interfaces. To achieve this, developers rely on various design patterns and JavaScript features to handle events and user interactions effectively. One such example is the Publisher-Subscriber pattern that can easily be implemented by using the EventTarget
interface.
In this article, we'll explore how to use the EventTarget
interface to easily and properly implement the Publisher-Subscriber pattern to handle events in the Web environment.
Wait... what exists before EventTarget
?
Before EventTarget
came to the browser land, when one needed to implement the Publisher-Subscriber pattern, they would probably have to build everything from scratch. A common code example is to create a PubSub
class that manages its internal events like this:
type EventCallback<T> = (data: T) => void;
class PubSub<T> {
#events: Record<string, EventCallback<T>[]> = {};
addEventListener(event: string, callback: EventCallback<T>): void {
if (!this.#events[event]) {
this.#events[event] = [];
}
this.#events[event].push(callback);
}
dispatchEvent(event: string, data: T): void {
const eventCallbacks = this.#events[event];
if (eventCallbacks && eventCallbacks.length > 0) {
eventCallbacks.forEach((callback) => {
callback(data);
});
}
}
removeEventListener(event: string, callback: EventCallback<T>): void {
const eventCallbacks = this.#events[event];
if (eventCallbacks && eventCallbacks.length > 0) {
this.#events[event] = eventCallbacks.filter((cb) => cb !== callback);
}
}
}
However, that would be a little bit cumbersome, and perhaps each programmer will likely implement it slightly differently, which can be error-prone.
Introducing EventTarget
EventTarget
is now supported by literally every browser. The EventTarget
interface represents an object that can receive events and have listeners for those events. Most DOM elements in a web page, such as buttons, forms, and input fields, implement the EventTarget interface. This allows developers to add event listeners to these elements and respond to user interactions, like clicks or keypresses. However, what's even cooler is that you can use it separately without attaching it to any DOM element.
In TypeScript, the EventTarget interface is defined as follows:
interface EventTarget {
addEventListener(type: string, callback: EventListenerOrEventListenerObject | null, options?: AddEventListenerOptions | boolean): void;
dispatchEvent(event: Event): boolean;
removeEventListener(type: string, callback: EventListenerOrEventListenerObject | null, options?: EventListenerOptions | boolean): void;
}
-
addEventListener
: Appends an event listener for events with the equivalent type, which can be any arbitrary name or pre-defined types likeclick
,keydown
, etc. The callback argument sets the callback that will be invoked when the event is dispatched. -
dispatchEvent
: Dispatches a custom event on the target element. -
removeEventListener
: Removes the event listener in target's event listener list with the sametype
,callback
, andoptions
.
How EventTarget
and CustomEvent
work together
You can always create a custom EventTarget
of your own by simply creating a class that extends EventTarget
and then instantiate it:
class CustomEventTarget extends EventTarget {}
const customEventTarget = new CustomEventTarget();
This (customEventTarget
) acts as a bridging component (a.k.a. a broker) where the data exchange happens. And with CustomEvent
, we can include additional data however we want through its detail
property:
const data = { whatever: 'you want' };
const customEvent = new CustomEvent('someCustomEventName', { detail: data });
customEventTarget.dispatchEvent(customEvent);
And then we can easily get that data in the event handler:
const eventHandler = (event) => {
const data = event.detail;
// do whatever next here
};
customEventTarget.addEventListener('someCustomEventName', eventHandler);
And don't forget to remove the event listener using removeEventListener
.
Make it less verbose and easily reusable
Creating a new event may seem like a hassle sometimes. Therefore, we can have this small piece of code to reduce the boilerplate:
class CustomEventTarget extends EventTarget {}
export const createPubsub = <T = Record<string, unknown>>(eventName: string) => {
const customEventTarget = new CustomEventTarget();
const subscribe = (eventHandler: (data: T) => void) => {
const scriptContentResizedEventHandler = (event: Event) => {
const data = (event as CustomEvent).detail as T;
eventHandler(data);
};
customEventTarget.addEventListener(eventName, scriptContentResizedEventHandler);
return () => {
customEventTarget.removeEventListener(eventName, scriptContentResizedEventHandler);
};
};
const publish = (data: T) => {
customEventTarget.dispatchEvent(new CustomEvent(eventName, { detail: data }));
};
return { subscribe, publish };
};
Now it only takes one line of code whenever we want to create a new pubsub:
export const customPubsubA = createPubsub<{ whatever: string }>('someCustomEvent');
And using the pubsub is also straightforward:
// Dispatch an event with some custom data
customPubsubA.publish({ whatever: 'you want' });
// To subscribe to the event:
const unsubscribe = customPubsubA.subscribe(({ whatever }) => {
// do whatever here
});
// ...and remember to unsubscribe when it is no longer needed
unsubscribe();
Conclusion
The simple yet powerful combination makes it a valuable tool in various scenarios. In some ReactJS projects, this pattern can often be used to avoid having to rely on unnecessary and complex state management or prop drilling.
In summary, the use of the EventTarget
interface and CustomEvent
provides a "standardized" way to manage events and listeners. It has greatly simplified the implementation of the Publisher-Subscriber pattern in web development.