Las dos lecciones anteriores han resuelto por completo la comunicación entre <task-card> y <task-list>: un evento sube, una propiedad baja, y ambos componentes se mantienen sincronizados sin que ninguno conozca la estructura interna del otro. Ese patrón funciona porque <task-card> y <task-list> tienen una relación directa de padre e hijo. Pero TaskFlow está a punto de necesitar algo distinto: un futuro componente <task-filter>, con controles para filtrar la lista de tareas visible, que no es hijo de <task-list> ni al revés, sino que ambos deberán convivir como hermanos bajo un mismo contenedor. Esta lección presenta el patrón estándar para resolver la comunicación entre componentes sin relación directa, y lo aplica creando <task-board>, el componente orquestador que dará cabida, a partir de ahora, a toda la estructura de TaskFlow.

Contenido

  1. El problema: dos hermanos que necesitan hablar entre sí
  2. El patrón de "levantar el estado" a un ancestro común
  3. Creando <task-board> como orquestador
  4. <task-board> escucha a <task-list>
  5. Preparando el hueco para <task-filter>
  6. Alternativas para aplicaciones más grandes
  7. Cierre: hacia el ciclo de vida y las actualizaciones

  1. El problema: dos hermanos que necesitan hablar entre sí

Imagina el escenario, todavía por construir en este curso, de <task-filter>: un componente con, por ejemplo, un <select> para elegir "mostrar solo tareas urgentes" o "mostrar solo tareas pendientes". Cuando el usuario cambie ese filtro, de algún modo <task-list> tiene que enterarse y dejar de mostrar las tarjetas que no cumplan el criterio elegido. El problema es que, si ambos componentes se colocan uno junto al otro en el HTML de la aplicación...

<task-filter></task-filter>
<task-list></task-list>

... no existe ninguna relación de padre e hijo entre ellos que permita aplicar directamente los patrones ya vistos. <task-filter> no puede pasarle una propiedad a <task-list> (las propiedades, como se recordó en la lección anterior, solo viajan de un componente a sus propios hijos en la plantilla), y aunque <task-filter> despachara un evento personalizado con bubbles: true y composed: true, ese evento subiría hacia sus ancestros comunes con <task-list>, no hacia <task-list> directamente, porque los eventos del DOM no tienen ninguna noción de "mandar esto a mi hermano": solo suben hacia arriba, nunca cruzan lateralmente entre ramas distintas del árbol.

  1. El patrón de "levantar el estado" a un ancestro común

La solución estándar, no exclusiva de Lit ni de Web Components (el mismo patrón se conoce, con el mismo nombre, en React y en otras muchas bibliotecas de interfaz basadas en componentes), consiste en levantar el estado compartido a un ancestro común de ambos hermanos: en lugar de que <task-filter> y <task-list> intenten hablar directamente entre sí, un tercer componente, padre de ambos, mantiene el dato que los dos necesitan compartir (en este caso, el criterio de filtro activo), y se convierte en el intermediario de toda la comunicación:

  • <task-filter>, cuando el usuario cambia el criterio, despacha un evento personalizado hacia arriba (exactamente el mismo patrón "hijo anuncia" de la lección 05-02), pero esta vez quien lo escucha es el ancestro común, no <task-list> directamente.
  • El ancestro común actualiza su propio estado con el nuevo criterio de filtro.
  • El ancestro común pasa ese criterio hacia abajo, como propiedad, a <task-list> (exactamente el mismo patrón "propiedad hacia el hijo" de la lección 05-03).
  • <task-list> usa esa propiedad para decidir qué tareas mostrar.

En ningún momento <task-filter> y <task-list> se conocen ni se comunican directamente entre sí: cada uno solo habla con su padre común, que es quien conoce a ambos y decide cómo coordinar la información entre ellos. Este es, en esencia, el mismo patrón de las dos lecciones anteriores (evento hacia arriba, propiedad hacia abajo), aplicado dos veces seguidas —una vez entre <task-filter> y el ancestro, otra vez entre el ancestro y <task-list>— en lugar de una sola vez entre dos componentes directamente emparentados.

  1. Creando <task-board> como orquestador

TaskFlow necesita, para aplicar este patrón, un nuevo componente que haga de ancestro común: <task-board>, el tablero completo de la aplicación, que a partir de ahora será quien contenga <task-list> (y, más adelante en el curso, <task-filter>), y quien mantenga el estado que ambos necesiten compartir.

// src/components/task-board.js
import { LitElement, html, css } from 'lit';
import { estilosCompartidos } from '../styles/shared-styles.js';
import './task-list.js';

class TaskBoard extends LitElement {
  static properties = {
    tareas: { type: Array },
  };

  static styles = [
    estilosCompartidos,
    css`
      .tablero {
        display: flex;
        flex-direction: column;
        gap: 1rem;
        padding: 1rem;
      }
    `,
  ];

  constructor() {
    super();
    this.tareas = [
      { id: 1, titulo: 'Preparar la demo del sprint', estado: 'en-progreso', prioridad: 4, urgente: true },
      { id: 2, titulo: 'Revisar el PR de autenticación', estado: 'pendiente', prioridad: 2, urgente: false },
      { id: 3, titulo: 'Desplegar a producción', estado: 'hecha', prioridad: 5, urgente: false },
    ];
  }

  render() {
    return html`
      <div class="tablero">
        <h1>TaskFlow</h1>
        <task-list .tareas="${this.tareas}"></task-list>
      </div>
    `;
  }
}

customElements.define('task-board', TaskBoard);

Este cambio traslada a <task-board> una responsabilidad que hasta ahora tenía <task-list>: el array tareas completo, con sus datos iniciales, ya no se inicializa dentro de <task-list>, sino en <task-board>, y baja hacia <task-list> como propiedad, con el binding de punto ya conocido (.tareas="${this.tareas}"). <task-list>, por su parte, deja de tener un constructor que inventa sus propios datos de ejemplo: a partir de ahora, recibe siempre tareas desde fuera, igual que <task-card> recibe siempre titulo o estado desde <task-list>. Esta redistribución de responsabilidades es exactamente lo que hace falta para que, más adelante, <task-filter> pueda incorporarse en el mismo nivel que <task-list>, ambos como hijos de <task-board>, sin que ninguno de los dos necesite mantener por sí solo el array completo de tareas.

  1. <task-board> escucha a <task-list>

El evento tarea-cambiada, que en la lección 05-03 subía de <task-card> a <task-list>, sigue exactamente igual: no cambia nada en <task-card>. Lo que sí cambia es dónde vive ahora la lógica que decide cómo actualizar el array tareas: como ese array ha pasado a vivir en <task-board>, es <task-board> quien debe encargarse de actualizarlo cuando una tarjeta cambia de estado, y <task-list> pasa a limitarse a reenviar el evento hacia arriba, sin gestionarlo por sí misma:

// src/components/task-list.js
class TaskList extends LitElement {
  static properties = {
    tareas: { type: Array },
  };

  render() {
    return html`
      <section>
        <h2>Mis tareas</h2>
        <div class="lista">
          ${this.tareas.map(
            (tarea) => html`
              <task-card
                .titulo="${tarea.titulo}"
                .estado="${tarea.estado}"
                .prioridad="${tarea.prioridad}"
                .urgente="${tarea.urgente}"
                @tarea-cambiada="${(event) => this.reenviarTareaCambiada(tarea.id, event)}"
              ></task-card>
            `
          )}
        </div>
      </section>
    `;
  }

  reenviarTareaCambiada(idTarea, event) {
    this.dispatchEvent(
      new CustomEvent('tarea-cambiada', {
        detail: { idTarea, nuevoEstado: event.detail.nuevoEstado },
        bubbles: true,
        composed: true,
      })
    );
  }
}
// src/components/task-board.js
class TaskBoard extends LitElement {
  // ...properties, styles y constructor sin cambios...

  gestionarTareaCambiada(event) {
    const { idTarea, nuevoEstado } = event.detail;
    this.tareas = this.tareas.map((tarea) =>
      tarea.id === idTarea ? { ...tarea, estado: nuevoEstado } : tarea
    );
  }

  render() {
    return html`
      <div class="tablero">
        <h1>TaskFlow</h1>
        <task-list .tareas="${this.tareas}" @tarea-cambiada="${this.gestionarTareaCambiada}"></task-list>
      </div>
    `;
  }
}

Este reenvío merece detenerse un momento en él, porque es la pieza nueva de esta lección: <task-list> recibe tarea-cambiada de una <task-card> concreta (sabe, gracias al cierre sobre tarea.id ya visto en la lección anterior, a qué tarea afecta), pero en lugar de decidir ella misma cómo actualizar el array, construye y despacha un nuevo evento personalizado, también llamado tarea-cambiada, esta vez con idTarea incluido explícitamente en su detail (algo que no hacía falta en la versión original, porque antes era la propia <task-list> quien ya conocía tarea.id por el cierre del map; ahora ese dato necesita viajar explícitamente en el evento, porque quien lo va a consumir, <task-board>, no tiene acceso a esa variable de cierre). El nuevo evento, con bubbles: true y composed: true igual que el original, sube desde <task-list> hasta <task-board>, que es quien finalmente aplica la lógica de actualización inmutable ya conocida de la lección anterior.

Es perfectamente válido, y de hecho habitual en aplicaciones reales con jerarquías más profundas, que un componente intermedio como <task-list> no gestione un evento por sí mismo sino que simplemente lo deje pasar hacia arriba, añadiendo la información adicional que haga falta por el camino. Este reenvío en cadena es, en esencia, la misma idea del burbujeo nativo de eventos del DOM (visto en la lección 05-01), pero aplicada de forma explícita y controlada entre distintos niveles de componentes personalizados.

  1. Preparando el hueco para <task-filter>

Con <task-board> ya en su sitio como orquestador, queda perfectamente preparado el terreno para que, cuando se construya <task-filter> (una tarea que corresponde a un módulo posterior de este curso), se incorpore exactamente en el mismo nivel que <task-list>, como hermano bajo el mismo <task-board>:

// Vista previa de cómo quedará <task-board> más adelante en el curso,
// una vez exista <task-filter> (todavía no implementado en este módulo)
render() {
  return html`
    <div class="tablero">
      <h1>TaskFlow</h1>
      <task-filter @filtro-cambiado="${this.gestionarFiltroCambiado}"></task-filter>
      <task-list
        .tareas="${this.tareasFiltradas()}"
        @tarea-cambiada="${this.gestionarTareaCambiada}"
      ></task-list>
    </div>
  `;
}

No hace falta implementar <task-filter> ni tareasFiltradas() en este módulo (ese desarrollo llegará más adelante en el curso, cuando se disponga de más herramientas de plantillas); lo importante, para esta lección, es constatar que la estructura que se acaba de construir —<task-board> como único punto que conoce tanto a <task-list> como, en el futuro, a <task-filter>— ya es capaz de dar cabida a ese componente adicional sin ningún cambio de arquitectura: <task-filter> despacharía su propio evento hacia arriba (por ejemplo, filtro-cambiado), <task-board> lo escucharía y actualizaría un nuevo estado interno o propiedad con el criterio activo, y ese criterio se combinaría con this.tareas (por ejemplo, en un método tareasFiltradas()) antes de pasarlo hacia <task-list>. Ni <task-filter> ni <task-list> necesitarían, en ningún momento, conocerse el uno al otro.

  1. Alternativas para aplicaciones más grandes

El patrón de levantar el estado a un ancestro común, aplicado en esta lección con un único nivel de <task-board>, escala razonablemente bien mientras la aplicación no crezca demasiado en profundidad. Pero conviene saber que, en aplicaciones más grandes, con jerarquías de muchos niveles o con muchos componentes que necesitan el mismo dato compartido, este patrón puede volverse incómodo: si el ancestro común estuviera varios niveles por encima, cada nivel intermedio tendría que reenviar eventos hacia arriba y propiedades hacia abajo, como ha hecho <task-list> en el apartado 4, únicamente para hacer de intermediario de un dato que ni siquiera usa él mismo. Este problema, en la literatura de componentes de interfaz, se conoce a veces como prop drilling (perforación de propiedades), y dos alternativas habituales lo evitan:

  • Un bus de eventos global: un objeto compartido, accesible desde cualquier componente de la aplicación (normalmente importado como un módulo de JavaScript), sobre el que cualquier componente puede despachar eventos y cualquier otro puede suscribirse, sin pasar por ninguna jerarquía de componentes intermedia. Resuelve el problema de comunicación entre componentes lejanos, pero a costa de perder la trazabilidad clara de "quién comunica con quién" que sí ofrece el árbol de componentes y sus eventos con bubbles/composed.
  • @lit/context, la API de contexto compartido del propio ecosistema de Lit, diseñada específicamente para que un componente ancestro publique un valor y cualquier descendiente, a cualquier profundidad, pueda consumirlo directamente, sin que los niveles intermedios necesiten reenviar nada manualmente. Es, en general, la alternativa más elegante para este tipo de problema dentro del ecosistema de Lit, y se estudiará en detalle en el módulo 7, "Directivas y Funcionalidades Avanzadas de Plantillas".

Para el tamaño actual de TaskFlow, con una jerarquía de solo tres niveles (<task-board><task-list><task-card>), el patrón de levantar el estado al ancestro común, con eventos y propiedades reenviados manualmente, es perfectamente adecuado y no necesita ninguna de estas dos alternativas; conviene conocerlas para reconocer, en un proyecto real más grande, el momento en que sí conviene recurrir a ellas.

  1. Cierre: hacia el ciclo de vida y las actualizaciones

Con <task-board> ya coordinando <task-list> y, en el futuro, <task-filter>, TaskFlow tiene ya toda la estructura de comunicación que necesitaba: eventos que suben contando lo que ha ocurrido, propiedades que bajan con el estado actualizado, y un ancestro común que hace de intermediario cuando dos componentes no tienen relación directa de padre e hijo.

Errores Comunes y Consejos

  • Hacer que <task-filter> y <task-list> intenten comunicarse directamente: por ejemplo, guardando una referencia a <task-list> dentro de <task-filter> con document.querySelector y llamando a sus métodos directamente. Esto rompe la encapsulación de ambos componentes (cada uno pasa a depender de la existencia y de la API interna del otro) y es exactamente lo que el patrón de esta lección evita.
  • Olvidar incluir en el detail del evento reenviado la información que se perdería al subir un nivel: como se explicó en el apartado 4, <task-list> tuvo que añadir explícitamente idTarea al detail del evento reenviado, porque esa información, disponible en <task-list> gracias al cierre del map, no estaría accesible de otro modo para <task-board>.
  • Duplicar el estado en varios niveles de la jerarquía: si tanto <task-board> como <task-list> mantuvieran su propia copia del array tareas, sincronizarlas correctamente se volvería innecesariamente complicado y propenso a errores; el estado compartido debe vivir en un único lugar (el ancestro común), y los descendientes deben limitarse a recibirlo como propiedad, nunca a mantener su propia copia independiente.
  • Recurrir a un bus de eventos global o a @lit/context antes de necesitarlo realmente: para jerarquías pequeñas, como la TaskFlow de este módulo, el patrón de levantar el estado a un ancestro común es más simple de seguir y depurar que introducir una capa adicional de indirección; conviene reservar esas alternativas, mencionadas en el apartado 6, para cuando la jerarquía o el número de componentes que comparten un dato lo justifiquen de verdad.

Ejercicios

  1. Añade a <task-board> un método contarTareasPendientes() que devuelva cuántas tareas de this.tareas tienen estado === 'pendiente', y muéstralo en la plantilla de <task-board>, junto al <h1>TaskFlow</h1>, como un pequeño resumen (por ejemplo, "3 tareas pendientes"). Explica por qué esta lógica encaja mejor en <task-board> que en <task-list>.
  2. Supón que se añade tarea-eliminada (del ejercicio 1 de la lección 05-02) al flujo completo de TaskFlow. Escribe el reenvío correspondiente en <task-list> (similar a reenviarTareaCambiada) y el manejador correspondiente en <task-board> que elimine la tarea de this.tareas de forma inmutable.
  3. Explica, con tus propias palabras y apoyándote en el apartado 2, por qué habría sido un error resolver el problema de esta lección haciendo que <task-card> despachara tarea-cambiada directamente contra una referencia guardada de <task-list> (por ejemplo, pasándole esa referencia como propiedad desde <task-board>), en lugar de dejar que el evento burbujee de forma natural hasta donde corresponda escucharlo.

Soluciones

contarTareasPendientes() {
  return this.tareas.filter((tarea) => tarea.estado === 'pendiente').length;
}

render() {
  return html`
    <div class="tablero">
      <h1>TaskFlow</h1>
      <p>${this.contarTareasPendientes()} tareas pendientes</p>
      <task-list .tareas="${this.tareas}" @tarea-cambiada="${this.gestionarTareaCambiada}"></task-list>
    </div>
  `;
}

Esta lógica encaja en <task-board> porque necesita acceso al array tareas completo, que es precisamente el dato que <task-board> mantiene como ancestro común; <task-list>, si tuviera esta misma lógica, necesitaría duplicar el criterio de conteo y aplicarlo también sobre su propia copia de tareas, cuando en realidad ambos deberían operar siempre sobre la misma fuente de verdad, la que vive en <task-board>.

// task-list.js
reenviarTareaEliminada(idTarea) {
  this.dispatchEvent(
    new CustomEvent('tarea-eliminada', {
      detail: { idTarea },
      bubbles: true,
      composed: true,
    })
  );
}
// task-board.js
gestionarTareaEliminada(event) {
  const { idTarea } = event.detail;
  this.tareas = this.tareas.filter((tarea) => tarea.id !== idTarea);
}
<task-list
  .tareas="${this.tareas}"
  @tarea-cambiada="${this.gestionarTareaCambiada}"
  @tarea-eliminada="${this.gestionarTareaEliminada}"
></task-list>
  1. Pasar una referencia directa a <task-list> como propiedad de <task-card> (o de <task-board> hacia <task-card>, saltándose a <task-list>) obligaría a <task-card> a conocer, aunque fuera indirectamente, la existencia y la API de <task-list>, exactamente la dependencia que la lección 05-02 buscaba evitar: <task-card> dejaría de ser un componente aislado y reutilizable, y pasaría a depender de una pieza concreta de la jerarquía de TaskFlow. Además, esa referencia directa se saltaría por completo el mecanismo de burbujeo con bubbles/composed, perdiendo la ventaja señalada en la lección 05-02 de que cualquier número de listeners, en cualquier nivel de la jerarquía, puede escuchar el mismo evento sin que el emisor necesite saber nada sobre ellos; con una referencia directa a un único destinatario, esa flexibilidad desaparece.

Conclusión

En esta lección se ha resuelto el problema de comunicar dos componentes sin relación directa de padre e hijo mediante el patrón de levantar el estado a un ancestro común, aplicando dos veces seguidas el ciclo ya conocido de evento hacia arriba y propiedad hacia abajo. <task-board> ha nacido como ese ancestro común, con el array tareas como fuente única de verdad, <task-list> reenviando eventos en lugar de gestionarlos por sí sola, y el terreno ya preparado para que <task-filter> se incorpore más adelante como hermano de <task-list> sin que ninguno de los dos necesite conocer al otro. También se han mencionado, sin entrar en detalle, dos alternativas para jerarquías más grandes: un bus de eventos global y @lit/context, esta última una alternativa más elegante que se estudiará con detalle en el módulo 7.

Todo este intercambio de eventos y propiedades que se ha construido a lo largo del módulo 5 tiene un efecto común, todavía no explicado en profundidad: cada vez que una propiedad reactiva cambia —ya sea estado en <task-card> o tareas en <task-board>— Lit programa una actualización, pero hasta ahora este curso ha dado por hecho, sin detenerse a explicarlo, cuándo exactamente ocurre esa actualización, en qué orden se ejecutan los distintos pasos del proceso, y qué posibilidades ofrece Lit para engancharse a momentos concretos de ese ciclo. Ese es exactamente el contenido del módulo 6, "Ciclo de Vida y Comportamiento Avanzado".

Curso de Lit

Módulo 1: Introducción a Lit y Web Components

Módulo 2: Plantillas Reactivas y Renderizado

Módulo 3: Propiedades y Estado Reactivo

Módulo 4: Estilos en Componentes Lit

Módulo 5: Eventos y Comunicación entre Componentes

Módulo 6: Ciclo de Vida y Comportamiento Avanzado

Módulo 7: Directivas y Funcionalidades Avanzadas de Plantillas

Módulo 8: Integración, Interoperabilidad y Despliegue

Módulo 9: Pruebas y Buenas Prácticas

Módulo 10: Proyecto: Construyendo TaskFlow

© Copyright 2026. Todos los derechos reservados