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');
- Companion code: the mediator-pattern-components companion repo packages these examples as runnable TypeScript files.
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:
- Mediator (
Mediatorinterface) declares the communication interface — a singlenotifyentry point. - Concrete Mediator (
DashboardMediator) owns the coordination logic: which colleagues react to which events. - Colleague (
FilterPanel,ChartWidget…) sends events through the mediator instead of calling peers directly. - Client wires the mediator with all colleagues, usually at composition-root time.
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: stringscales 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
subscribereturns 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.
Related Resources
Mediator Pattern
Define an object that encapsulates how a set of objects interact. A behavioral design pattern for reducing chaotic dependencies.
PatternObserver Pattern
Define a subscription mechanism to notify multiple objects about events. A behavioral design pattern for event-driven communication.
PatternFacade Pattern
Provide a simplified interface to a complex subsystem. A structural pattern that hides implementation details behind a clean API.
PatternComposite Pattern for UI Component Trees in React
Use the Composite pattern to compose objects into tree structures, letting clients treat individual objects and compositions uniformly in UI component hierarchies
PatternBridge Pattern for Decoupling UI Components from Themes
Separate an abstraction from its implementation so both can vary independently using the Bridge pattern for pluggable UI themes and rendering engines
PatternBackend for Frontend (BFF) Pattern
Give each client type its own backend that aggregates downstream services and shapes data to that platform's needs.