Observer Pattern
One subject, many subscribers — the shape underneath event emitters, stores, and every reactive library you've used.
An observable keeps a list of subscribers and calls them all when something happens. That's it. Every event emitter, store, and signal implementation is this with more features bolted on.
The mechanism
function createObservable() {
const observers = new Set();
return {
subscribe(fn) {
observers.add(fn);
// Returning the teardown is the important bit — see below.
return () => observers.delete(fn);
},
notify(data) {
for (const fn of observers) fn(data);
},
};
}
const clicks = createObservable();
const unsubscribe = clicks.subscribe((e) => console.log("logged", e.target));
clicks.subscribe((e) => sendAnalytics(e));
document.addEventListener("click", (e) => clicks.notify(e));
unsubscribe(); // the logger stops; analytics keeps goingA Set rather than an array, so subscribing the same function twice doesn't
fire it twice, and removal is O(1) instead of an indexOf splice.
Why the returned unsubscribe matters
If subscribe returns nothing, the caller has to hold onto the exact function
reference to ever remove it — and inline arrow functions make that impossible:
clicks.subscribe((e) => console.log(e)); // this can never be removedHanding back a closure that knows how to remove itself is what makes the
subscription disposable. It's also why React's useEffect cleanup and this
pattern fit together so neatly — the effect returns the unsubscribe.
The subject holds a reference to every subscriber function, and through the closure, to everything that function captured. A component that subscribes and never unsubscribes keeps its whole scope alive after it's gone.
The one thing to fix before shipping it
The naive notify above breaks in two ways.
A throwing subscriber kills the rest of the list — subscriber three never runs because subscriber two had a bad day. And mutating the set during iteration (a subscriber that unsubscribes itself, which is common) is undefined-ish behaviour you don't want to reason about.
notify(data) {
// Snapshot, so unsubscribing mid-notify doesn't disturb this pass.
for (const fn of [...observers]) {
try {
fn(data);
} catch (error) {
// One bad subscriber shouldn't silence the others.
console.error("observer failed", error);
}
}
}Where you already use it
addEventListener. IntersectionObserver, MutationObserver, ResizeObserver
— named after the pattern. Redux's store.subscribe. RxJS, which is this plus
operators and a contract about completion.
Knowing the shape means you can read any of them the same way: who's the subject, who subscribes, and who is responsible for tearing it down.