Observer Pattern
The observer pattern lets an object — the subject — maintain a list of dependents, called observers, and notify them automatically whenever its state changes. Instead of components polling each other or calling methods directly, observers subscribe once and receive updates as they happen. This one-to-many relationship is the foundation of event systems, reactive UIs, and data-binding libraries, because it decouples the thing that changes from the things that care about the change.
How the pattern works
A subject keeps a registry of observers and exposes three operations: subscribe (add an observer), unsubscribe (remove one), and notify (call every observer with the new data). Observers know nothing about each other; the subject knows nothing about what observers actually do. That separation is the whole point — you can add or remove listeners without touching the code that produces the events.
┌─────────┐ notify() ┌────────────┐
│ Subject │ ──────────▶ │ Observer A │
│ state │ ──────────▶ │ Observer B │
└─────────┘ ──────────▶ │ Observer C │
▲ └────────────┘
│ subscribe() / unsubscribe()
A minimal observable
Here is a small Observable that tracks a value and notifies subscribers whenever it changes. The subscribe method returns an unsubscribe function — a common ergonomic convention that frees callers from holding onto a token.
class Observable {
#value;
#observers = new Set();
constructor(initial) {
this.#value = initial;
}
get value() {
return this.#value;
}
set value(next) {
if (next === this.#value) return; // skip no-op updates
this.#value = next;
this.#notify(next);
}
subscribe(fn) {
this.#observers.add(fn);
return () => this.#observers.delete(fn); // unsubscribe handle
}
#notify(value) {
for (const fn of this.#observers) fn(value);
}
}
const temperature = new Observable(20);
const unsubscribe = temperature.subscribe((t) => console.log(`Now ${t}°C`));
temperature.value = 21;
temperature.value = 21; // ignored — same value
temperature.value = 25;
unsubscribe();
temperature.value = 30; // no output, observer is gone
Output:
Now 21°C
Now 25°C
Using a Set gives free de-duplication (subscribing the same function twice registers it once) and O(1) removal. Guarding against unchanged values prevents redundant notifications, which matters for reactive systems that re-render on every emit.
A general-purpose EventEmitter
When you need named channels rather than a single value, generalize to an emitter keyed by event name. This is the shape of Node’s EventEmitter and the DOM’s EventTarget.
class EventEmitter {
#channels = new Map();
on(event, listener) {
if (!this.#channels.has(event)) this.#channels.set(event, new Set());
this.#channels.get(event).add(listener);
return () => this.off(event, listener);
}
once(event, listener) {
const wrapper = (...args) => {
this.off(event, wrapper);
listener(...args);
};
return this.on(event, wrapper);
}
off(event, listener) {
this.#channels.get(event)?.delete(listener);
}
emit(event, ...args) {
// Copy so listeners can safely unsubscribe during dispatch
for (const fn of [...(this.#channels.get(event) ?? [])]) fn(...args);
}
}
const bus = new EventEmitter();
bus.on("login", (user) => console.log(`Welcome, ${user.name}`));
bus.once("login", () => console.log("First login handler fires once"));
bus.emit("login", { name: "Ada" });
bus.emit("login", { name: "Grace" });
Output:
Welcome, Ada
First login handler fires once
Welcome, Grace
Always copy the listener collection before iterating in
emit. A listener that unsubscribes itself (likeonce) mutates the set mid-loop, and iterating a liveSetyou are deleting from can skip handlers.
Real-world uses
You already use the observer pattern constantly — it underpins many browser and Node APIs.
| API | Subject | Subscribe | Notify |
|---|---|---|---|
| DOM events | EventTarget | addEventListener | dispatched event |
| Node streams | EventEmitter | on("data") | emit("data") |
IntersectionObserver | observer instance | observe(el) | callback on intersect |
| Reactive state | signal/store | subscribe | set/assignment |
The DOM is the canonical example: addEventListener is subscribe, and the browser is the subject that notifies you when a click occurs.
const button = document.querySelector("#save");
const handler = () => console.log("Saved!");
button.addEventListener("click", handler);
// Later — unsubscribe so the listener can be garbage collected
button.removeEventListener("click", handler);
Modern reactivity (Vue’s ref, Svelte stores, Solid signals) is the observer pattern with automatic dependency tracking: reading a reactive value subscribes the current computation, and writing it notifies every subscriber to re-run.
Best Practices
- Return an unsubscribe function from
subscribe/onso callers do not need to retain the original listener reference. - Store listeners in a
Setto dedupe automatically and remove in O(1). - Copy the listener list before dispatching, so handlers can add or remove listeners during notification without corrupting iteration.
- Always provide a way to unsubscribe — forgotten listeners are a classic memory leak, especially on long-lived DOM nodes and emitters.
- Isolate listener failures: wrap each call in
try/catch(or dispatch on a microtask) so one throwing observer does not block the rest. - Prefer
AbortController+{ signal }withaddEventListenerto tear down many listeners at once. - Keep observer callbacks fast and side-effect-light; offload heavy work rather than blocking the notification loop.