Skip to content

Observer Pattern and Event Emitter

advanced18 min read

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.

Mental Model

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)
Quiz
Why does the subscribe method return a function instead of requiring a separate unsubscribe(observer) call?

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.

Quiz
What is the relationship between addEventListener and the Observer pattern?

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() });
Quiz
What advantage does the typed event emitter provide over a plain EventTarget?

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 doWhat 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.

Challenge:

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)
Key Rules
  1. 1The Observer pattern decouples producers from consumers — the subject never imports or knows about its observers
  2. 2Always return an unsubscribe function from subscribe to prevent memory leaks and enable cleanup in useEffect
  3. 3Use a Set for observer storage — O(1) add/delete and no duplicate subscriptions
  4. 4Guard against re-entrant notifications (observer triggers another notify) to prevent infinite loops
  5. 5React's useSyncExternalStore is a standardized Observer contract — any store that implements subscribe + getSnapshot works with React
1/10