StackPractices
intermediate By Mathias Paulenko

Mediator Pattern for Decoupled UI Components

Reduce chaotic dependencies between UI components by introducing a mediator that centralizes communication, preventing explicit references between peers

Overview

The Mediator pattern defines an object that encapsulates how a set of objects interact. Instead of components referring to each other directly, they refer to a mediator, collapsing explicit connections from many-to-many to many-to-one. The result: adding or removing a participant touches one file — the mediator — instead of every colleague on the dashboard. This resource applies it to a TypeScript dashboard — filters, charts, tables and maps that must stay synchronized. For the general-purpose version, see the Mediator pattern.

When to Use

  • Components have many-to-many relationships that would otherwise create tight coupling
  • Reusing a component independently is hard because it depends on specific peers
  • Communication logic is scattered across components and difficult to test
  • You want one place to route, filter, or log every interaction

Skip it when:

  • Two or three widgets share a simple parent — lifting state or a callback prop is enough, and a mediator is just ceremony
  • Interactions are one-way notifications with no response logic — a plain Observer covers that
  • You already use a state manager; adding a hand-rolled mediator beside Redux or Zustand splits your wiring across two routing tables instead of one
  • The team can’t agree on keeping the mediator thin — a god object is worse than the coupling it replaced

Problem

A dashboard with filters, charts, tables, and maps requires each widget to notify every other widget when data changes. Each widget holds references to 5-6 others — a dependency nightmare where adding a new widget means touching all the existing ones, and testing one in isolation means mocking its whole neighborhood. It scales worse than it looks: n widgets create n×(n-1) potential communication channels.

Solution

Every colleague sends events to the mediator; the mediator decides who reacts. Widgets know nothing about each other — the N×(N-1) web of references becomes N connections to a single hub.

// mediator/DashboardMediator.ts
interface Mediator {
  notify(sender: Component, event: string, data?: unknown): void;
}

abstract class Component {
  constructor(protected mediator: Mediator) {}

  send(event: string, data?: unknown): void {
    this.mediator.notify(this, event, data);
  }
}

class FilterPanel extends Component {
  private selectedRegion = 'all';

  selectRegion(region: string): void {
    this.selectedRegion = region;
    this.send('region-changed', region);
  }
}

class ChartWidget extends Component {
  private data: unknown[] = [];

  updateData(data: unknown[]): void {
    this.data = data;
    this.render();
  }

  private render(): void {
    console.log('Chart rendered with', this.data.length, 'points');
  }
}

class TableWidget extends Component {
  private rows: unknown[] = [];

  updateRows(rows: unknown[]): void {
    this.rows = rows;
    console.log('Table updated with', rows.length, 'rows');
  }
}

class MapWidget extends Component {
  private center = { lat: 0, lng: 0 };

  panTo(center: { lat: number; lng: number }): void {
    this.center = center;
    console.log('Map centered at', center);
  }
}

// Mediator orchestrates all communication
class DashboardMediator implements Mediator {
  private filters: FilterPanel;
  private chart: ChartWidget;
  private table: TableWidget;
  private map: MapWidget;

  setComponents(
    filters: FilterPanel,
    chart: ChartWidget,
    table: TableWidget,
    map: MapWidget
  ): void {
    this.filters = filters;
    this.chart = chart;
    this.table = table;
    this.map = map;
  }

  notify(sender: Component, event: string, data?: unknown): void {
    switch (event) {
      case 'region-changed': {
        const filteredData = this.fetchDataForRegion(data as string);
        this.chart.updateData(filteredData);
        this.table.updateRows(filteredData);
        this.map.panTo(this.getRegionCenter(data as string));
        break;
      }
      case 'chart-point-clicked': {
        const point = data as { lat: number; lng: number };
        this.map.panTo(point);
        break;
      }
    }
  }

  private fetchDataForRegion(region: string): unknown[] {
    return [{ id: 1, region }];
  }

  private getRegionCenter(region: string): { lat: number; lng: number } {
    const centers: Record<string, { lat: number; lng: number }> = {
      'north': { lat: 45, lng: 0 },
      'south': { lat: -45, lng: 0 },
    };
    return centers[region] || { lat: 0, lng: 0 };
  }
}

// Usage
const mediator = new DashboardMediator();
const filters = new FilterPanel(mediator);
const chart = new ChartWidget(mediator);
const table = new TableWidget(mediator);
const map = new MapWidget(mediator);

mediator.setComponents(filters, chart, table, map);
filters.selectRegion('north');

Variation: Event Bus Mediator

A looser mediator variant: components subscribe to named events instead of the mediator knowing each widget by name. Useful when the routing table changes at runtime.

// mediator/EventBus.ts
class EventBus {
  private listeners = new Map<string, Set<(data: unknown) => void>>();

  subscribe(event: string, callback: (data: unknown) => void): () => void {
    if (!this.listeners.has(event)) {
      this.listeners.set(event, new Set());
    }
    this.listeners.get(event)!.add(callback);

    return () => this.listeners.get(event)?.delete(callback);
  }

  emit(event: string, data?: unknown): void {
    this.listeners.get(event)?.forEach(cb => cb(data));
  }
}

const bus = new EventBus();
bus.subscribe('user-login', user => console.log('Logged in:', user));
bus.emit('user-login', { id: 1 });

Note subscribe returns an unsubscribe function — the caller is responsible for calling it, or you get the classic memory leak of zombie listeners. Unlike DashboardMediator, the bus doesn’t type-check or route; any component can emit any event, which buys flexibility at the cost of discoverability — you can’t tell who reacts to what without grepping for the string.

How It Works

The pattern has four roles:

  1. Mediator (Mediator interface) declares the communication interface — a single notify entry point.
  2. Concrete Mediator (DashboardMediator) owns the coordination logic: which colleagues react to which events.
  3. Colleague (FilterPanel, ChartWidget…) sends events through the mediator instead of calling peers directly.
  4. Client wires the mediator with all colleagues, usually at composition-root time.
flowchart diagram: FilterPanel

Adding a fifth widget means registering it with the mediator — the existing four never learn it exists. Removing one is just as surgical: unregister it, and its events simply stop arriving.

Scenario: Chat Room

The same shape works for chat — users don’t know each other, the room routes every message:

// Mediator pattern: centralize communication between components
interface ChatMediator {
  sendMessage(msg: string, user: User): void;
  registerUser(user: User): void;
}

abstract class User {
  constructor(protected mediator: ChatMediator, public name: string) {}
  abstract send(msg: string): void;
  abstract receive(msg: string): void;
}

class ChatUser extends User {
  send(msg: string) { this.mediator.sendMessage(msg, this); }
  receive(msg: string) { console.log(`[${this.name}] received: ${msg}`); }
}

class ChatRoom implements ChatMediator {
  private users: User[] = [];

  registerUser(user: User) { this.users.push(user); }

  sendMessage(msg: string, sender: User) {
    // Broadcast to all except sender
    this.users.filter(u => u !== sender).forEach(u => u.receive(msg));
  }
}

const chat = new ChatRoom();
const alice = new ChatUser(chat, "Alice");
const bob = new ChatUser(chat, "Bob");
chat.registerUser(alice);
chat.registerUser(bob);

alice.send("Hello everyone");
// [Bob] received: Hello everyone

Lessons: the room can filter, transform, or log messages without users knowing; registering a new user changes nothing else; and coupling drops from N×(N-1) to N — each participant only ever names the mediator.

Trade-offs

  • Mediator can become a god object: put every routing rule in one class and you’ve rebuilt the mess you escaped — split by domain when it grows.
  • Stringly-typed events: event: string scales poorly; prefer a union of literal types or an enum so the compiler catches typos.
  • Hidden interactions: reading a component no longer tells you what it triggers — the wiring lives in the mediator, so navigation gets indirect.
  • Debuggability: a single routing table is also a single place to breakpoint and log — a real win when interactions get complex.

Common Mistakes

  • Creating a mediator so large it becomes unmaintainable — the same trap as the monolithic controller it was meant to replace.
  • Bypassing the mediator for direct component communication “just this once”, which quietly reintroduces the coupling.
  • Not unsubscribing event listeners, causing memory leaks — the reason subscribe returns a cleanup function.
  • Confusing it with Observer: Observer broadcasts one-to-many and the subject ignores who listens; Mediator routes many-to-many and knows every colleague.

Further Reading

Frequently Asked Questions

How is this different from Observer?

Observer is one-to-many broadcast — the subject fires and doesn't care who listens. Mediator is many-to-many routing through a coordinator that knows every colleague and decides who reacts.

When should I use a state manager instead?

When the primary need is shared state, not just communication. Mediator routes messages; libraries like Redux or Zustand manage data. In practice state managers are evolved mediators — pick them when synchronization rules outgrow a hand-rolled bus.

Is this pattern suitable for small projects?

Usually no. Two or three widgets talking through a parent component or a callback prop don't need a hub — the pattern pays off once the interaction matrix gets painful to trace.

How does Mediator relate to CQRS?

CQRS buses are mediators applied to commands and queries — handlers register for a message type and the bus routes it. Same central coordinator, different domain.

Can I partially apply this pattern?

Yes. Route only the tangled interactions through the mediator — dashboard sync, wizard steps — and keep simple parent-child props direct. The pattern tolerates scoped adoption.