Observer

Advanced · 11 min read · ▶ live playground · ✦ checkpoint

A spreadsheet recalculates a cell and updates its chart, but the audit log still shows the old value. The developer who added logging remembered one edit path and missed another because Spreadsheet calls every dependent by name.

The Observer pattern lets one object notify many dependents automatically when its state changes. It removes hard-coded dependent calls from the changing object, but it makes update flow less visible and can turn one change into a difficult-to-trace cascade.

Before: every dependent gets another direct call

The spreadsheet starts with a chart and a total panel. Adding an audit log expands its constructor, fields, and update method:

class Chart {
  public redraw(value: number): void {
    // Render the new value.
  }
}
 
class TotalPanel {
  public recalculate(value: number): void {
    // Recalculate the displayed total.
  }
}
 
class AuditLog {
  public record(value: number): void {
    // Store the change.
  }
}
 
class Spreadsheet {
  private value = 0;
 
  public constructor(
    private chart: Chart,
    private totalPanel: TotalPanel,
    private auditLog: AuditLog,
  ) {}
 
  public setCell(value: number): void {
    this.value = value;
    this.chart.redraw(value);
    this.totalPanel.recalculate(value);
    this.auditLog.record(value);
  }
}

Spreadsheet now depends on three concrete reactions that are not part of storing a cell. A notification email needs a fourth field and call. A test of cell updates must construct every collaborator, and a second mutation method can forget one of them.

Extracting updateChart(), updateTotal(), and writeAudit() into private methods shortens setCell(), but the spreadsheet still owns the dependent list. The coupling has moved without shrinking.

After: publish one change through a stable contract

The subject is the object whose change is being observed. An observer is a dependent object that registers interest and receives an update through a shared interface.

To subscribe means to register an observer for future updates; to notify means to call the registered observers when the subject changes. The subject stores a collection of the interface, not a field for every concrete reaction.

class diagram

Spreadsheet notifies dependents through the Observer contract

Spreadsheet notifies dependents through the Observer contract
  1. 1Observer gives every dependent the same update(value) entry point.
  2. 2Spreadsheet is the subject; it owns a list of Observer references rather than concrete views.
  3. 3ChartObserver reacts by redrawing, without adding chart knowledge to Spreadsheet.
  4. 4AuditObserver is another subscriber; adding it does not change the subject's notification loop.

Spreadsheet is still responsible for deciding when its state is consistent enough to publish. Once it calls notify(), every registered observer gets the same value:

interface Observer {
  update(value: number): void;
}
 
class Spreadsheet {
  private value = 0;
  private observers: Observer[] = [];
 
  public subscribe(observer: Observer): () => void {
    this.observers.push(observer);
 
    return () => {
      this.observers = this.observers.filter(
        (candidate) => candidate !== observer,
      );
    };
  }
 
  public setCell(value: number): void {
    this.value = value;
    this.notify();
  }
 
  public notify(): void {
    for (const observer of [...this.observers]) {
      observer.update(this.value);
    }
  }
}
 
class ChartObserver implements Observer {
  public update(value: number): void {
    console.log(`chart redraw: ${value}`);
  }
}
 
class AuditObserver implements Observer {
  public update(value: number): void {
    console.log(`audit log: ${value}`);
  }
}
 
const sheet = new Spreadsheet();
sheet.subscribe(new ChartObserver());
sheet.subscribe(new AuditObserver());
 
sheet.setCell(42);

chart redraw: 42 audit log: 42

Subscription order determines the output here because notify() calls observers synchronously. The copied array gives this notification pass a stable subscriber list even if an observer unsubscribes while handling the value. It does not prevent an observer from triggering another spreadsheet update.

The returned function is an unsubscribe function, a callback that removes the same observer later. Long-lived subjects need that exit path; otherwise, a subject can retain objects that the rest of the application no longer uses.

This example uses a push model, where the subject sends the changed value in update(value). A pull model sends the subject or no payload, and each observer reads the state it needs. Push can send too much data to some observers; pull can cause repeated reads and exposes more of the subject.

Where this pattern already appears

UI event listeners follow the same shape. A button is the subject, click handlers subscribe, and one click notifies every registered handler. Reactive state tools build a richer version around the same idea: dependents register interest, state changes, and affected work runs.

Observer keeps the subject unaware of each reaction, but it does not make those reactions independent. If ChartObserver assumes AuditObserver ran first, the two observers have hidden order coupling. When one reaction must precede another, model that sequence explicitly instead of relying on subscription order.

An event object can also replace the bare number when notifications need identity and context. For example, { cell: "B4", previous: 40, current: 42 } tells an audit observer more than 42. Keep that event contract focused; a giant payload makes every observer depend on subject internals again.

When NOT to use it

For one fixed dependent, a direct method call is often clearer. A reader can follow it from the mutation to the reaction without finding who subscribed during startup. Observer earns its indirection when dependents vary, several reactions share the same event, or publishers must remain independent of subscribers.

Hidden update cascades are the main cost. When observer A changes another subject, which notifies observer B, a single setCell() can travel through a graph that no call site displays. Diagnosing who fired what, and in what order, requires event tracing or careful logs.

Synchronous notification can also cause reentrancy, where an observer calls back into the subject before the original update has finished. That can duplicate work, violate invariants, or recurse forever. A queue can defer reactions, but then timing, failure, and whether observers see the new state immediately become part of the design.

At application scale, an event bus is a shared broker that routes named events between publishers and subscribers. A reactive library tracks dependencies and schedules updates across a larger state graph. Those tools may fit better than hand-built observer lists, but they increase the distance between cause and effect and need their own diagnostics.

Observer trades direct navigation for an extensible subscriber list. Use it when the one-to-many relationship is real, define whether delivery is synchronous, document ordering guarantees, and make unsubscription visible.

Checkpoint

Answer all three to mark this lesson complete

Observer broadcasts one state change to many reactions. Next, Command turns one request into an object that can be stored, replayed, and undone.

+50 XP on completion