Observer Pattern and Event Emitter
The Pattern Behind Everything Reactive
Here's a puzzle. You write a shopping cart object. Three completely unrelated parts of your UI need to update when the cart changes — a badge counter in the header, a sidebar total, and an analytics tracker. How do you wire them together without the cart knowing about any of them?
cart.addItem({ id: 1, name: "Keyboard", price: 79 });
// Header badge: "1 item"
// Sidebar total: "$79.00"
// Analytics: track("item_added", { id: 1 })
If the cart directly calls each updater, you've created a tightly coupled mess. Add a fourth consumer and you're editing cart code. Remove analytics and the cart breaks. The Observer pattern solves this by inverting the dependency — the cart doesn't know who's listening. It just announces changes, and anyone interested subscribes.
Think of the Observer pattern like a newspaper subscription. The newspaper (subject) doesn't knock on every door in town to deliver news. Instead, people subscribe. When a new edition is printed, it goes to every subscriber automatically. Subscribers can cancel anytime without the newspaper needing to restructure its printing process. The newspaper has zero knowledge of what subscribers do with the content — read it, wrap fish in it, whatever.
The Core Structure
The Observer pattern has two roles: a Subject (the thing being watched) and Observers (the things reacting to changes). The subject maintains a list of observers and notifies them when something happens.
type Observer<T> = (data: T) => void;
class Subject<T> {
private observers: Set<Observer<T>> = new Set();
subscribe(observer: Observer<T>): () => void {
this.observers.add(observer);
return () => this.observers.delete(observer);
}
notify(data: T): void {
this.observers.forEach(observer => observer(data));
}
}
A few things to notice. We use a Set instead of an array — duplicates are silently ignored, and deletion is O(1) instead of O(n). The subscribe method returns an unsubscribe function, which is the same pattern React's useEffect cleanup uses. And the generic T makes this reusable for any data shape.
const cart = new Subject<{ items: string[]; total: number }>();
const unsubBadge = cart.subscribe(({ items }) => {
console.log(`Badge: ${items.length} items`);
});
const unsubTotal = cart.subscribe(({ total }) => {
console.log(`Total: $${total.toFixed(2)}`);
});
cart.notify({ items: ["Keyboard"], total: 79 });
// Badge: 1 items
// Total: $79.00
unsubBadge();
cart.notify({ items: ["Keyboard", "Mouse"], total: 104 });
// Total: $104.00 (badge observer is gone)
The DOM Already Uses This Pattern
The browser's EventTarget API is an Observer implementation. Every addEventListener is a subscription. Every dispatchEvent is a notification. Every removeEventListener is an unsubscription.
const button = document.querySelector("#submit");
function handleClick(e: Event) {
console.log("Clicked!", e);
}
button.addEventListener("click", handleClick);
button.removeEventListener("click", handleClick);
You can even build custom event systems on top of EventTarget:
class CartEvents extends EventTarget {
addItem(item: { id: number; name: string }) {
this.dispatchEvent(
new CustomEvent("item-added", { detail: item })
);
}
}
const cart = new CartEvents();
cart.addEventListener("item-added", ((e: CustomEvent) => {
console.log("Added:", e.detail.name);
}) as EventListener);
cart.addItem({ id: 1, name: "Keyboard" });
This works in both browsers and Node.js (since v15). But for most frontend patterns, you'll want something lighter than the full EventTarget API.
Production Scenario: Building a Typed Event Emitter
Real-world event emitters need multiple event types. Here's a type-safe version that catches mistakes at compile time:
type EventMap = Record<string, unknown>;
class TypedEmitter<T extends EventMap> {
private listeners = new Map<keyof T, Set<(data: never) => void>>();
on<K extends keyof T>(event: K, listener: (data: T[K]) => void): () => void {
if (!this.listeners.has(event)) {
this.listeners.set(event, new Set());
}
const set = this.listeners.get(event)!;
set.add(listener as (data: never) => void);
return () => set.delete(listener as (data: never) => void);
}
emit<K extends keyof T>(event: K, data: T[K]): void {
this.listeners.get(event)?.forEach(listener => listener(data));
}
once<K extends keyof T>(event: K, listener: (data: T[K]) => void): () => void {
const unsub = this.on(event, (data) => {
unsub();
listener(data);
});
return unsub;
}
}
Now TypeScript enforces that you emit the right data for each event:
interface AppEvents {
"user:login": { userId: string; timestamp: number };
"cart:update": { items: string[]; total: number };
"theme:change": "light" | "dark";
}
const bus = new TypedEmitter<AppEvents>();
bus.on("user:login", (data) => {
// data is typed as { userId: string; timestamp: number }
console.log(`User ${data.userId} logged in`);
});
bus.on("cart:update", (data) => {
// data is typed as { items: string[]; total: number }
console.log(`Cart total: $${data.total}`);
});
// Type error: Argument of type 'string' is not assignable
// bus.emit("user:login", "wrong data");
bus.emit("user:login", { userId: "abc", timestamp: Date.now() });
React's Subscription Model Is Observer
React's useSyncExternalStore hook is a formalized Observer interface. It expects a subscribe function that follows the exact same pattern:
import { useSyncExternalStore } from "react";
function createStore<T>(initialValue: T) {
let value = initialValue;
const subscribers = new Set<() => void>();
return {
getValue: () => value,
setValue: (next: T) => {
value = next;
subscribers.forEach(fn => fn());
},
subscribe: (callback: () => void) => {
subscribers.add(callback);
return () => subscribers.delete(callback);
},
};
}
const counterStore = createStore(0);
function Counter() {
const count = useSyncExternalStore(
counterStore.subscribe,
counterStore.getValue
);
return (
<button onClick={() => counterStore.setValue(count + 1)}>
Count: {count}
</button>
);
}
Notice how subscribe returns an unsubscribe function — that's React telling you "we use the Observer pattern internally." Zustand, Jotai, and Redux all follow this exact contract under the hood.
Handling Observer Pitfalls
The Observer pattern has a few sharp edges that catch people in production.
Memory Leaks from Forgotten Unsubscriptions
The most common bug. If an observer subscribes but never unsubscribes, it stays in memory forever — even if the component or module that created it is long gone.
// MEMORY LEAK: this listener is never removed
useEffect(() => {
store.subscribe(() => setCount(store.getValue()));
// Missing: return the unsubscribe function
}, []);
// CORRECT
useEffect(() => {
const unsub = store.subscribe(() => setCount(store.getValue()));
return unsub; // Cleanup on unmount
}, []);
Notification During Mutation
If an observer modifies the subject's state during notification, you can get infinite loops or inconsistent state:
const subject = new Subject<number>();
subject.subscribe((value) => {
if (value < 10) {
subject.notify(value + 1); // Recursive notification!
}
});
subject.notify(1); // Stack overflow
The fix is to queue notifications and flush them asynchronously, or guard against re-entrance:
class SafeSubject<T> {
private observers: Set<Observer<T>> = new Set();
private isNotifying = false;
private pendingNotifications: T[] = [];
notify(data: T): void {
if (this.isNotifying) {
this.pendingNotifications.push(data);
return;
}
this.isNotifying = true;
this.observers.forEach(observer => observer(data));
this.isNotifying = false;
if (this.pendingNotifications.length > 0) {
const next = this.pendingNotifications.shift()!;
this.notify(next);
}
}
}
| What developers do | What they should do |
|---|---|
| Subscribing in useEffect without returning the unsubscribe function Without cleanup, the observer stays registered after the component unmounts, causing memory leaks and state updates on unmounted components | Always return the unsubscribe function from useEffect cleanup |
| Using an array to store observers and indexOf to remove them Arrays make removal O(n) and allow duplicate subscriptions, which cause double-firing bugs that are extremely hard to track down | Use a Set for O(1) add/delete and automatic deduplication |
| Having the Subject hold strong references to observer objects Strong references to observer objects prevent garbage collection. If the observer component is destroyed but the subscription remains, the entire object stays in memory | Store only the callback function, or use WeakRef for object observers |
Challenge
Build an ObservableMap that extends Map and notifies observers whenever entries are added, updated, or deleted.
Try to solve it before peeking at the answer.
// Requirements:
// 1. Extends Map<string, number>
// 2. Has an on() method that accepts "set" | "delete" events
// 3. Notifies observers with { key, value?, previousValue? }
// 4. on() returns an unsubscribe function
// Usage:
const map = new ObservableMap<string, number>();
const unsub = map.on("set", (e) => {
console.log(`${e.key}: ${e.previousValue} → ${e.value}`);
});
map.set("score", 10); // "score: undefined → 10"
map.set("score", 20); // "score: 10 → 20"
unsub();
map.set("score", 30); // (no output)- 1The Observer pattern decouples producers from consumers — the subject never imports or knows about its observers
- 2Always return an unsubscribe function from subscribe to prevent memory leaks and enable cleanup in useEffect
- 3Use a Set for observer storage — O(1) add/delete and no duplicate subscriptions
- 4Guard against re-entrant notifications (observer triggers another notify) to prevent infinite loops
- 5React's useSyncExternalStore is a standardized Observer contract — any store that implements subscribe + getSnapshot works with React