The Observer pattern — or why a doorbell doesn't know who's home, or care what they do about it
A doorbell doesn't know who's home, or what they'll do when it rings — the kid runs for the door, the dog loses its mind, someone just pauses the show. Ringing is the doorbell's whole job; reacting is everyone else's. JavaScript's Observer pattern works the same way: a subject broadcasts that something happened, and any number of listeners — none of them known to each other, or to the subject — decide for themselves what to do about it. Here it is, in one small, real example.
The issue
A doorbell doesn't know how many people are in the house, who they are, or what they'll do when it rings. It doesn't need to — it just makes noise, once, and it's up to whoever's listening to decide what that noise means to them. That's the whole idea behind JavaScript's Observer pattern: a subject announces that something happened, and any number of listeners — added and removed over time, never known to each other, never known in advance — react however they see fit.
Say your product has a shopping cart, and three completely separate parts of the page need to react the instant it changes: a badge in the header showing the item count, an order summary panel showing the running subtotal, and an analytics call logging what got added. All three exist for different reasons, owned by different parts of the codebase, and none of them should have to know about the other two.
The naive version
Nothing exotic — a cart class whose addItem method also happens to know about three unrelated parts of the UI:
class ShoppingCart {
items = [];
addItem(item) {
this.items.push(item);
updateBadge(this.items.length); // from ./header.js
renderSubtotal(this.items); // from ./summary.js
logAnalyticsEvent("item_added", item); // from ./analytics.js
}
}Fix: subscribe / notify
The cart shouldn't call anyone by name. It should keep a set of listeners, let anything subscribe to it, and call notify once per change — the same signal, to however many listeners happen to exist:
class ObservableCart {
#listeners = new Set();
items = [];
subscribe(listener) {
this.#listeners.add(listener);
return () => this.#listeners.delete(listener);
}
notify() {
for (const listener of this.#listeners) listener(this.items);
}
addItem(item) {
this.items.push(item);
this.notify();
}
}
// usage
const cart = new ObservableCart();
const stop = cart.subscribe((items) => console.log("cart:", items.length));
cart.addItem({ sku: "mug-01", price: 12 }); // cart: 1
cart.addItem({ sku: "pen-02", price: 3 }); // cart: 2
stop(); // the token from subscribe()
cart.addItem({ sku: "cap-03", price: 18 }); // nothing logs — that listener is goneNothing that reacts to the cart lives inside cart.js anymore. Each listener subscribes from wherever it actually belongs, and addItem never changes shape no matter how many of them exist:
const cart = new ObservableCart();
cart.subscribe((items) => updateBadge(items.length));
cart.subscribe((items) => renderSubtotal(items));
cart.subscribe((items) => logAnalyticsEvent("item_added", items.at(-1)));
cart.addItem({ sku: "mug-01", price: 12 });
// all three fire — cart.js never imported header.js, summary.js, or analytics.jsThe other half: unsubscribing
subscribe handing back its own unsubscribe function matters most in exactly the place you'd actually use this — a component that comes and goes. Subscribe on mount, and call the function you got back on unmount, or the listener — and the closure it's holding onto — outlives the component that created it:
useEffect(() => {
const unsubscribe = cart.subscribe((items) => setCount(items.length));
return unsubscribe; // runs on unmount — nothing left listening after that
}, []);Where Observer can bite you
The thing that makes the pattern work — the subject not knowing who's listening — is also the thing that makes it hard to debug. Three ways that shows up:
Observer isn't free — it trades "I can see every caller in this file" for "I have no idea how many listeners exist, and I have to trust each one to clean up after itself." That trade is worth it exactly when the thing changing shouldn't have to know who's watching — a cart, a WebSocket feed, a form's validity — and not worth it for the one-shot request/response calls a plain Promise already handles fine.
For the built-in version of this — EventTarget, addEventListener, and cleanup via AbortSignal — plus more on when Observer is the wrong tool, patterns.dev's Observer Pattern writeup is a good next stop.