← Writing
The Editorial · Engineering

The Observer pattern — or why a doorbell doesn't know who's home, or care what they do about it

Issue 015Jul 26, 20265 min read

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.

the shape of an Observercartnotify() runs herebadgesubtotalanalyticsone subject, any number of listeners — none known to each other
Fig 1The pattern in general: the subject doesn't hold a fixed list of who cares — it just calls notify(), and whoever's currently subscribed hears about it.AI-generated figure

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:

cart.jsJavaScript
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
  }
}
addItem() calling three modules directlycart.js — addItem()updateBadge()imported directlyrenderSubtotal()imported directlylogAnalyticsEvent()imported directlycart.js has to import all three —a fourth listener means editing cart.js again
Fig 2No subject, no listeners — just addItem() reaching directly into three modules it has no other reason to import.AI-generated figure

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:

cart.js — the class, and what using it looks likeJavaScript
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 gone
ObservableCart: subscribe() joins, notify() broadcasts#listeners (Set)notify(items) loops over itbadge(items)subtotal(items)analytics(items)cart.js calls notify() once — it never counts or names who's listening
Fig 3Same addItem, one new collection — notify() loops over whatever's in #listeners right now, without cart.js ever naming who that is.AI-generated figure

Nothing 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:

header.js, summary.js, analytics.jsJavaScript
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.js

The 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:

CartBadge.jsxJavaScript
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.

All issues