StackPractices
intermediate Por Mathias Paulenko

Patrón Mediator para Componentes UI Desacoplados

Reduce dependencias caóticas entre componentes UI introduciendo un mediador que centraliza la comunicación y evita referencias explícitas entre pares

Descripción general

El patrón Mediator define un objeto que encapsula cómo interactúa un conjunto de objetos. En lugar de que los componentes se referencien entre sí directamente, se dirigen a un mediador, reduciendo las conexiones explícitas de many-to-many a many-to-one. Este recurso lo aplica a un dashboard en TypeScript —filtros, gráficos, tablas y mapas que deben mantenerse sincronizados. Para la versión general, consulta el patrón Mediator.

Cuándo usarlo

  • Los componentes tienen relaciones many-to-many que de otro modo crearían acoplamiento fuerte
  • Reusar un componente de forma independiente es difícil porque depende de pares concretos
  • La lógica de comunicación está dispersa entre componentes y es difícil de probar
  • Quieres un único punto para enrutar, filtrar o registrar cada interacción

Evítalo cuando:

  • Dos o tres widgets comparten un padre sencillo —elevar el estado o un callback por props basta, y un mediador es pura ceremonia
  • Las interacciones son notificaciones de un solo sentido sin lógica de respuesta —un Observer plano cubre eso
  • Ya usas un state manager; añadir un mediador casero junto a Redux o Zustand reparte tu cableado entre dos tablas de enrutado en lugar de una
  • El equipo no se compromete a mantener el mediador delgado —un god object es peor que el acoplamiento que reemplazó

Problema

Un dashboard con filtros, gráficos, tablas y mapas exige que cada widget notifique a todos los demás cuando cambian los datos. Cada widget guarda referencias a otros 5-6 —una pesadilla de dependencias donde añadir un widget nuevo obliga a tocar todos los existentes, y probar uno de forma aislada exige mockear todo su vecindario.

Solución

Cada colega envía eventos al mediador; el mediador decide quién reacciona. Los widgets no se conocen entre sí —la red de referencias N×(N-1) se convierte en N conexiones hacia un único 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);
  }
}

// El mediador orquesta toda la comunicación
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 };
  }
}

// Uso
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');

Variación: Event Bus Mediator

Una variante más suelta del mediador: los componentes se suscriben a eventos nombrados en vez de que el mediador conozca cada widget por su nombre. Útil cuando la tabla de enrutado cambia en 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 });

Fíjate en que subscribe devuelve una función de desuscripción —es responsabilidad del llamador ejecutarla, o aparece el clásico memory leak de listeners zombie. A diferencia de DashboardMediator, el bus no comprueba tipos ni enruta; cualquier componente puede emitir cualquier evento, lo que da flexibilidad a costa de descubribilidad —no puedes saber quién reacciona a qué sin buscar el string.

Cómo funciona

El patrón tiene cuatro roles:

  1. Mediator (interfaz Mediator) declara la interfaz de comunicación —un único punto de entrada notify.
  2. Concrete Mediator (DashboardMediator) alberga la lógica de coordinación: qué colegas reaccionan a qué eventos.
  3. Colleague (FilterPanel, ChartWidget…) envía eventos al mediador en lugar de llamar directamente a sus pares.
  4. Client conecta el mediador con todos los colegas, normalmente en el composition root.
flowchart diagram: FilterPanel

Añadir un quinto widget significa registrarlo en el mediador —los cuatro existentes nunca se enteran de que existe.

Escenario: Chat Room

La misma forma sirve para un chat —los usuarios no se conocen, la sala enruta cada mensaje:

// Patrón Mediator: centralizar la comunicación entre componentes
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) {
    // Emitir a todos excepto al remitente
    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("Hola a todos");
// [Bob] received: Hola a todos

Lecciones: la sala puede filtrar, transformar o registrar mensajes sin que los usuarios lo sepan; registrar un usuario nuevo no cambia nada más; y el acoplamiento baja de N×(N-1) a N —cada participante solo nombra al mediador.

Compromisos

  • El mediador puede volverse un god object: si metes todas las reglas de enrutado en una clase, reconstruyes el lío del que escapabas —divídelo por dominio cuando crezca.
  • Eventos stringly-typed: event: string escala mal; prefiere una unión de literales o un enum para que el compilador detecte erratas.
  • Interacciones ocultas: leer un componente ya no dice qué dispara —el cableado vive en el mediador y la navegación se vuelve indirecta.
  • Depurabilidad: una única tabla de enrutado es también un único punto donde poner breakpoints y logs —una ventaja real cuando las interacciones se complican.

Errores comunes

  • Crear un mediador tan grande que se vuelve inmantenible —la misma trampa del controlador monolítico que pretendía sustituir.
  • Saltarse el mediador para comunicación directa entre componentes “solo esta vez”, reintroduciendo el acoplamiento sin ruido.
  • No desuscribir los listeners de eventos, provocando memory leaks —por eso subscribe devuelve una función de limpieza.
  • Confundirlo con Observer: Observer emite one-to-many y el sujeto ignora quién escucha; Mediator enruta many-to-many y conoce a cada colega.

Lecturas recomendadas

Preguntas frecuentes

¿En qué se diferencia de Observer?

Observer es broadcast one-to-many —el sujeto dispara y le da igual quién escucha. Mediator es enrutado many-to-many a través de un coordinador que conoce a todos los colegas y decide quién reacciona.

¿Cuándo debería usar un state manager en su lugar?

Cuando la necesidad principal es estado compartido, no solo comunicación. Mediator enruta mensajes; librerías como Redux o Zustand gestionan datos. En la práctica los state managers son mediadores evolucionados —elígelos cuando las reglas de sincronización superen un bus hecho a mano.

¿Sirve este patrón para proyectos pequeños?

Normalmente no. Dos o tres widgets hablando por un componente padre o un callback no necesitan un hub —el patrón compensa cuando la matriz de interacciones duele de rastrear.

¿Qué relación tiene Mediator con CQRS?

Los buses de CQRS son mediadores aplicados a comandos y consultas —los handlers se registran para un tipo de mensaje y el bus lo enruta. Mismo coordinador central, otro dominio.

¿Puedo aplicar el patrón parcialmente?

Sí. Enruta por el mediador solo las interacciones enredadas —sincronización del dashboard, pasos de un wizard— y deja los props padre-hijo simples como están. El patrón admite adopción acotada.