The Observer Pattern in JavaScript
The Observer Pattern (also called Publish/Subscribe or Pub/Sub) is one of the most widely used design patterns in JavaScript. At its core, it defines a one-to-many dependency: when one object (the subject) changes state, all its observers are automatically notified. You use this pattern every time you call addEventListener — and understanding it will help you build decoupled, event-driven architectures.
The Problem It Solves
Imagine a shopping cart that needs to update the header badge count, the checkout sidebar, and a local storage cache whenever an item is added. Without a pattern, you'd tightly couple all three pieces of logic together:
function addItem(item) {
cart.push(item);
updateHeaderBadge(); // tightly coupled
updateSidebar(); // tightly coupled
saveToLocalStorage(); // tightly coupled
}
Every time you add a new feature, you modify addItem. The Observer pattern inverts this: components subscribe to events they care about, and the subject simply publishes — no knowledge of who's listening required.
Building an EventEmitter from Scratch
class EventEmitter {
constructor() {
this.events = {};
}
on(event, listener) {
if (!this.events[event]) {
this.events[event] = [];
}
this.events[event].push(listener);
return this; // Enable chaining
}
off(event, listener) {
if (!this.events[event]) return this;
this.events[event] = this.events[event].filter(l => l !== listener);
return this;
}
once(event, listener) {
const wrapper = (...args) => {
listener(...args);
this.off(event, wrapper);
};
return this.on(event, wrapper);
}
emit(event, ...args) {
if (!this.events[event]) return false;
this.events[event].forEach(listener => listener(...args));
return true;
}
}
Using the EventEmitter
const cart = new EventEmitter();
// Subscribe — each function is completely independent
cart.on("item:added", (item) => {
console.log(`Header: Cart now has items (+${item.name})`);
});
cart.on("item:added", (item) => {
console.log(`Sidebar: ${item.name} — $${item.price}`);
});
cart.once("item:added", () => {
console.log("Welcome! First item added — showing promo.");
});
// Publish — no knowledge of subscribers needed
function addToCart(item) {
// ... actual cart logic ...
cart.emit("item:added", item);
}
addToCart({ name: "Laptop Stand", price: 49.99 });
// Header: Cart now has items (+Laptop Stand)
// Sidebar: Laptop Stand — $49.99
// Welcome! First item added — showing promo.
addToCart({ name: "USB Hub", price: 29.99 });
// Header and Sidebar fire again, but NOT the once() handler
Key Methods Explained
| Method | Description |
|---|---|
on(event, fn) | Subscribe a listener to an event |
off(event, fn) | Unsubscribe a specific listener |
once(event, fn) | Subscribe a listener that fires only once, then removes itself |
emit(event, ...args) | Publish an event with optional data payload |
Real-World Applications
- DOM events: The browser's built-in
addEventListeneris the Observer pattern. - Node.js EventEmitter: The backbone of streams, HTTP servers, and most core Node modules.
- State management: Redux's store notifies subscribed components of state changes.
- WebSockets: Real-time messages are pushed to all subscribed handlers.
Pitfalls to Watch Out For
- Memory leaks: Always call
off()to remove listeners when a component is destroyed. - Event name collisions: Use namespaced event names like
"cart:item:added"in large applications. - Debugging complexity: Events that trigger other events can create hard-to-trace chains. Keep event logic simple.
Conclusion
The Observer pattern is fundamental to event-driven JavaScript. Building your own EventEmitter, even if you end up using a library's implementation, gives you a clear mental model of how frameworks like React, Vue, and Node.js work under the hood. It's one of the most valuable patterns to have in your toolkit.