Observer Pattern — When and How
A practical guide to the Observer pattern: implementing event-driven architectures with type-safe event emitters in TypeScript.
The Observer pattern defines a one-to-many dependency between objects: when one object (the subject) changes state, all its dependents (observers) are notified automatically. It’s the foundation of event-driven programming.
The Core Concept
// Type-safe Event Emitter
type EventMap = Record<string, any>;
type EventHandler<T = any> = (data: T) => void;
class TypedEventEmitter<Events extends EventMap> {
private handlers = new Map<keyof Events, Set<EventHandler>>();
on<K extends keyof Events>(event: K, handler: EventHandler<Events[K]>): () => void {
if (!this.handlers.has(event)) {
this.handlers.set(event, new Set());
}
this.handlers.get(event)!.add(handler);
// Return unsubscribe function
return () => this.handlers.get(event)?.delete(handler);
}
emit<K extends keyof Events>(event: K, data: Events[K]): void {
this.handlers.get(event)?.forEach(handler => handler(data));
}
once<K extends keyof Events>(event: K, handler: EventHandler<Events[K]>): void {
const unsubscribe = this.on(event, (data) => {
handler(data);
unsubscribe();
});
}
}
Real-World Example: Shopping Cart
// Define events for the cart
interface CartEvents {
'item:added': { item: CartItem; total: number };
'item:removed': { itemId: string; total: number };
'cart:cleared': { previousTotal: number };
'cart:checkout': { items: CartItem[]; total: number };
}
class ShoppingCart extends TypedEventEmitter<CartEvents> {
private items: CartItem[] = [];
addItem(item: CartItem): void {
this.items.push(item);
this.emit('item:added', { item, total: this.total });
}
removeItem(itemId: string): void {
this.items = this.items.filter(i => i.id !== itemId);
this.emit('item:removed', { itemId, total: this.total });
}
checkout(): void {
this.emit('cart:checkout', { items: [...this.items], total: this.total });
const prevTotal = this.total;
this.items = [];
this.emit('cart:cleared', { previousTotal: prevTotal });
}
get total(): number {
return this.items.reduce((sum, item) => sum + item.price * item.quantity, 0);
}
}
// Observers — each handles its own concern
const cart = new ShoppingCart();
// Analytics observer
cart.on('item:added', ({ item }) => {
analytics.track('add_to_cart', { productId: item.id, price: item.price });
});
// UI observer
cart.on('item:added', ({ total }) => {
updateCartBadge(cart.items.length);
updateCartTotal(total);
});
// Inventory observer
cart.on('cart:checkout', ({ items }) => {
items.forEach(item => inventory.reserve(item.id, item.quantity));
});
// Logging observer
cart.on('cart:checkout', ({ total }) => {
logger.info(`Checkout completed: $${total}`);
});
When to Use Observer
- Decoupled notifications: The subject doesn’t know (or care) who’s listening
- Multiple reactions to one event: Adding a new reaction doesn’t require changing the subject
- Plugin architectures: Third-party code can hook into your system via events
- UI updates: React to state changes without tight coupling
When NOT to Use Observer
- Simple sequential logic: If there’s only ever one handler, a direct function call is clearer
- Order-dependent operations: Observers don’t guarantee execution order
- Error handling is critical: If one observer fails, should others still run?
- Debugging complexity: Event-driven flows can be hard to trace
Practical Tips
// Always clean up subscriptions to prevent memory leaks
class UserDashboard {
private unsubscribers: (() => void)[] = [];
mount(cart: ShoppingCart): void {
this.unsubscribers.push(
cart.on('item:added', (data) => this.updateUI(data)),
cart.on('cart:cleared', () => this.resetUI()),
);
}
unmount(): void {
this.unsubscribers.forEach(unsub => unsub());
this.unsubscribers = [];
}
}
The Observer pattern is everywhere — from DOM events to RxJS to Node.js EventEmitter. Master it and you’ll see it in a new light.
“The Observer pattern lets you define a subscription mechanism to notify multiple objects about events that happen to the object they’re observing.” — Refactoring Guru