Observer — The Mechanism the Platform Already Has
This chapter covers how far the standard APIs get you and where a notification mechanism of your own becomes necessary.
The problem this pattern set out to solve
GoF catalogs Observer as a publish-subscribe mechanism that lets many observers receive one event1.
When the notifying side knows its recipients by name, every added recipient means rewriting the notifier. Observer makes recipients register themselves, which cuts the notifier's dependency on them.
This pattern has a side that is a design technique and a side that is implementation labor. The former still holds. The latter changes depending on whether the runtime already ships the same mechanism.
Writing it straightforwardly with classes
The subject here is reporting download progress. This version keeps its own registry.
type ProgressDetail = {loaded: number; total: number};
interface ProgressObserver {
onProgress(detail: ProgressDetail): void;
}
class DownloadJob {
private readonly observers: ProgressObserver[] = [];
subscribe(observer: ProgressObserver): void {
this.observers.push(observer);
}
unsubscribe(observer: ProgressObserver): void {
const index = this.observers.indexOf(observer);
if (index >= 0) {
this.observers.splice(index, 1);
}
}
report(detail: ProgressDetail): void {
for (const observer of this.observers) {
observer.onProgress(detail);
}
}
}
It works, but unsubscribe has a problem. You cannot unsubscribe unless you kept the same reference you registered with. Register by passing an anonymous object and there is no way left to unsubscribe. A subscription that lingers keeps the observing object from being released.
Replacing it with language features
Both browsers and Node.js have EventTarget.
The
addEventListener()method of theEventTargetinterface sets up a function that will be called whenever the specified event is delivered to the target.2
For unsubscribing, there is a mechanism that saves you from carrying a reference around.
An
AbortSignal. The listener will be removed when theabort()method of theAbortControllerwhich owns theAbortSignalis called.2
That is what does the work here. Unsubscribing can be expressed as a lifetime rather than as the subscriber's bookkeeping.
type ProgressDetail = {loaded: number; total: number};
class DownloadJob extends EventTarget {
report(detail: ProgressDetail): void {
this.dispatchEvent(new CustomEvent<ProgressDetail>('progress', {detail}));
}
}
const job = new DownloadJob();
const controller = new AbortController();
job.addEventListener(
'progress',
(event) => {
const {loaded, total} = (event as CustomEvent<ProgressDetail>).detail;
console.log(`${Math.round((loaded / total) * 100)}%`);
},
{signal: controller.signal},
);
job.report({loaded: 512, total: 1024});
// Unsubscribe everything at once, for instance when the screen closes
controller.abort();
The registry management code is gone, and missed unsubscriptions become less likely. Pass the same signal to several subscriptions and one abort() removes them all.
Part of it goes untyped
One cast, as CustomEvent<ProgressDetail>, remains in the example above. What addEventListener hands you is typed Event, so you have to restate the type of what is inside detail yourself. Type checking does not reach here.
To keep the cast from spreading across callers, give the notifying side a typed entry point.
type ProgressDetail = {loaded: number; total: number};
class DownloadJob extends EventTarget {
report(detail: ProgressDetail): void {
this.dispatchEvent(new CustomEvent<ProgressDetail>('progress', {detail}));
}
// Keep the cast confined to this method
onProgress(
listener: (detail: ProgressDetail) => void,
options?: {signal?: AbortSignal},
): void {
this.addEventListener(
'progress',
(event) => listener((event as CustomEvent<ProgressDetail>).detail),
options,
);
}
}
const job = new DownloadJob();
const controller = new AbortController();
job.onProgress(({loaded, total}) => {
console.log(`${Math.round((loaded / total) * 100)}%`);
}, {signal: controller.signal});
Callers no longer write a cast, and detail comes typed. The cast is collected in one place.
Wrapping changes the behavior, though. onProgress passes a new function to addEventListener on every call, so registering the same function twice gets it called twice. Plain addEventListener does not register the same reference twice. Unsubscribing individually also goes away, so unsubscription consolidates onto signal.
How this series judges it
Verdict: conditional. It comes down to how many kinds of events there are and how much typing you demand.
- A handful of event kinds, with unsubscription management as the main concern, means
EventTarget— there is no reason to build your own registry. Bulk unsubscription throughAbortSignalis exactly the part you would end up building if you rolled your own. - Wanting argument types kept strictly separate per event kind means a typed mechanism of your own —
EventTargetassumes string event names and theEventtype, so per-kind typing has to be filled in with casts or wrappers. With a dozen-plus kinds each carrying differently shaped data, a mechanism you can look up by type from the start reads better. - Just distributing UI state means not writing an Observer at all — the framework's state management already has that job. Building your own notification mechanism ends up duplicating the framework's re-rendering.
Even when you build your own, shaping the registry and unsubscription to accept an AbortSignal lets it combine with the standard tools.
What would overturn this judgment
EventTarget is available in Node.js as well, but Node.js has historically had another notification mechanism (EventEmitter), and existing libraries sometimes assume that one. Which one your dependencies use decides what you line up with. That choice is not about which language feature is better; it is about consistency with what surrounds you.
One more thing: dispatchEvent calls subscribers synchronously. Heavy work on the subscriber's side makes the notifier wait, and you decide for yourself what happens when a subscriber throws. Whether a subscriber's failure may propagate to the notifier is something to settle before picking a mechanism.
How this relates to existing articles
- Generic constraints has an example of building a typed event emitter. Looking up argument types from the event name is covered there. It is the concrete form of what this chapter's judgment calls "a typed mechanism of your own," so it works as the implementation reference if you go that way. This chapter approaches things from the standard APIs, so neither the subject nor the structure overlaps.