La lección anterior ha consolidado los tres componentes que muestran datos: <user-avatar>, <task-card> y <task-filter>. Esta lección cierra el árbol de TaskFlow con las dos piezas que lo orquestan —<task-board> y <task-list>— junto con toda la infraestructura compartida que ambos usan: el contexto de filtro, el controlador reactivo de proximidad de fecha, el mixin de estado de carga, y el servicio que simula la obtención de tareas desde una fuente externa. En el camino se cierra, por fin, una pieza que quedó pendiente desde el módulo 7: cómo hacer que <task-list> siga recibiendo tareas actualizadas después de que la carga inicial, resuelta una única vez con until, ya haya terminado.

Contenido

  1. Qué queda por consolidar
  2. El contexto de filtro, completo
  3. El controlador y el mixin, completos
  4. El servicio de carga simulada, completo
  5. <task-list>, completo
  6. <task-board>, completo: propiedades, contexto y carga inicial
  7. Cerrando un hueco: mantener <task-list> sincronizada después de until
  8. Tabla final: qué conecta a cada par de componentes
  9. Hacia el cierre del curso

  1. Qué queda por consolidar

<task-board> y <task-list> son los dos componentes de TaskFlow con más piezas de infraestructura alrededor: un mixin, un contexto, un servicio simulado y una directiva de renderizado asíncrono, todos ellos explicados en los módulos 6 y 7. Antes de escribir el código completo de ambos, esta lección reúne primero, uno a uno, los tres ficheros de apoyo que ambos dan por hecho: el contexto de filtro (src/context/filtro-context.js), el controlador y el mixin (src/controllers/ y src/mixins/), y el servicio de carga (src/services/tareas-service.js).

  1. El contexto de filtro, completo

El fichero de contexto es, de los tres, el más pequeño: una única clave, sin ningún valor por defecto embebido en el propio módulo (el valor inicial se declara donde se instancia el ContextProvider, dentro de <task-board>).

// src/context/filtro-context.js
import { createContext } from '@lit/context';

// Módulo 7 (07-04): identificador único de contexto, no el valor en sí.
export const filtroContext = createContext('filtro-tareas');

  1. El controlador y el mixin, completos

ContadorTiempoRestanteController, usado por <task-card> desde el módulo 6, no ha cambiado ni una línea desde su lección de origen:

// src/controllers/contador-tiempo-restante-controller.js
// Módulo 6 (06-03): lógica con estado propio y ciclo de vida, reutilizable
// por cualquier host que exponga una propiedad `fechaLimite`.
export class ContadorTiempoRestanteController {
  constructor(host, { margenMs = 24 * 60 * 60 * 1000, intervaloMs = 60000 } = {}) {
    this.host = host;
    this.margenMs = margenMs;
    this.intervaloMs = intervaloMs;
    this.cercaDeVencer = false;
    host.addController(this);
  }

  hostConnected() {
    this._comprobar();
    this._idIntervalo = setInterval(() => this._comprobar(), this.intervaloMs);
  }

  hostDisconnected() {
    clearInterval(this._idIntervalo);
  }

  _comprobar() {
    const nuevoValor = this._calcularSiCercaDeVencer(this.host.fechaLimite);
    if (nuevoValor !== this.cercaDeVencer) {
      this.cercaDeVencer = nuevoValor;
      this.host.requestUpdate();
    }
  }

  _calcularSiCercaDeVencer(fechaLimite) {
    if (!fechaLimite) {
      return false;
    }
    const msRestantes = fechaLimite.getTime() - Date.now();
    return msRestantes > 0 && msRestantes <= this.margenMs;
  }
}

ConEstadoCarga, el mixin que <task-board> usará en el apartado 6, tampoco cambia desde el módulo 6:

// src/mixins/con-estado-carga.js
// Módulo 6 (06-04): comportamiento que se integra en la propia API del
// componente, no en un objeto separado como el controlador anterior.
import { html } from 'lit';

export const ConEstadoCarga = (Base) => class extends Base {
  static properties = {
    ...Base.properties,
    cargando: { state: true },
  };

  constructor(...args) {
    super(...args);
    this.cargando = false;
  }

  conIndicadorDeCarga(plantilla) {
    if (this.cargando) {
      return html`<p class="cargando">Cargando…</p>`;
    }
    return plantilla;
  }
};

<task-board> sigue extendiendo ConEstadoCarga(LitElement), tal como se decidió en el módulo 6, aunque —como se verá en el apartado 6— la carga inicial de tareas use until en lugar de this.cargando; el mixin queda disponible, con su propiedad cargando y su método conIndicadorDeCarga, para cualquier operación futura de TaskFlow (guardar cambios, sincronizar con un servidor) que encaje mejor con ese patrón, siguiendo exactamente el criterio de la tabla comparativa de la lección "Renderizado Asíncrono con until" (07-03, apartado 7).

  1. El servicio de carga simulada, completo

El servicio de la lección 07-03 devolvía tareas con solo cuatro campos, suficientes en aquel momento para explicar until. Con <task-card> ya completo desde la lección anterior, con soporte para persona asignada y fecha límite, este es el momento de completar también los datos de ejemplo:

// src/services/tareas-service.js
// Módulo 7 (07-03): simula una fuente de datos externa con un retraso.
// Los datos de ejemplo se completan aquí con los campos que <task-card>
// terminó necesitando en módulos posteriores (asignado, fecha límite).
export function cargarTareas() {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve([
        {
          id: 1,
          titulo: 'Preparar la demo del sprint',
          estado: 'en-progreso',
          prioridad: 4,
          urgente: true,
          asignadoA: 'Ana Costa',
          asignadoImagen: '',
          fechaLimite: new Date(Date.now() + 12 * 60 * 60 * 1000),
        },
        {
          id: 2,
          titulo: 'Revisar el PR de autenticación',
          estado: 'pendiente',
          prioridad: 2,
          urgente: false,
          asignadoA: 'Marc Puig',
          asignadoImagen: '',
          fechaLimite: null,
        },
        {
          id: 3,
          titulo: 'Desplegar a producción',
          estado: 'hecha',
          prioridad: 5,
          urgente: false,
          asignadoA: 'Ana Costa',
          asignadoImagen: '',
          fechaLimite: null,
        },
      ]);
    }, 1200);
  });
}

La primera tarea, con una fecha límite dentro de las próximas doce horas, garantiza que ContadorTiempoRestanteController y resaltarSiUrgente tengan, desde el primer momento en que TaskFlow arranca, un caso real que activar sin necesidad de esperar ni de modificar los datos a mano.

  1. <task-list>, completo

<task-list> reúne el consumo del contexto de filtro (07-04), el filtrado derivado (07-04, refinado en 09-03) y el reenvío del evento hacia <task-board> (05-04):

// src/components/task-list.js
import { LitElement, html, css } from 'lit';
import { repeat } from 'lit/directives/repeat.js';
import { ContextConsumer } from '@lit/context';
import { filtroContext } from '../context/filtro-context.js';
import { estilosCompartidos } from '../styles/shared-styles.js';
import './task-card.js';

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

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

  constructor() {
    super();
    this.tareas = [];
    // Módulo 7 (07-04): consumidor del contexto de filtro publicado por <task-board>.
    this._filtro = new ContextConsumer(this, { context: filtroContext, subscribe: true });
  }

  // Módulo 7 (07-04) / Módulo 9 (09-03): getter simple, sin caché; para el
  // volumen de datos de TaskFlow, moverlo a willUpdate no aporta ninguna
  // mejora perceptible, como se razonó en la lección de rendimiento.
  get tareasFiltradas() {
    const { texto, estado } = this._filtro.value ?? { texto: '', estado: 'todas' };
    const textoNormalizado = texto.toLowerCase();
    return this.tareas.filter((tarea) => {
      const coincideEstado = estado === 'todas' || tarea.estado === estado;
      const coincideTexto = tarea.titulo.toLowerCase().includes(textoNormalizado);
      return coincideEstado && coincideTexto;
    });
  }

  // Módulo 5 (05-04): no gestiona el evento, lo reenvía hacia arriba con
  // el idTarea añadido explícitamente, porque <task-board> no tiene
  // acceso al cierre sobre `tarea.id` que sí existe aquí dentro del repeat.
  reenviarTareaCambiada(idTarea, event) {
    this.dispatchEvent(
      new CustomEvent('tarea-cambiada', {
        detail: { idTarea, nuevoEstado: event.detail.nuevoEstado },
        bubbles: true,
        composed: true,
      })
    );
  }

  // Módulo 2 (02-04) / Módulo 9 (09-03): repeat con clave estable, para que
  // el filtro pueda insertar y eliminar tarjetas sin perder el estado
  // interno (como `expandida`) de las que permanecen visibles.
  render() {
    return html`
      <section>
        <h2>Mis tareas</h2>
        <div class="lista">
          ${repeat(
            this.tareasFiltradas,
            (tarea) => tarea.id,
            (tarea) => html`
              <task-card
                .titulo="${tarea.titulo}"
                .estado="${tarea.estado}"
                .prioridad="${tarea.prioridad}"
                .urgente="${tarea.urgente}"
                .fechaLimite="${tarea.fechaLimite}"
                asignado-a="${tarea.asignadoA}"
                asignado-imagen="${tarea.asignadoImagen}"
                @tarea-cambiada="${(event) => this.reenviarTareaCambiada(tarea.id, event)}"
              ></task-card>
            `
          )}
        </div>
      </section>
    `;
  }
}

customElements.define('task-list', TaskList);

Un detalle que ninguna lección anterior había mostrado de forma explícita: .fechaLimite="${tarea.fechaLimite}" usa el binding de propiedad con punto, no el atributo fecha-limite con el conversor de la lección 03-03. Cuando el valor ya es, en origen, un objeto Date real (como aquí, procedente de tareas-service.js), no hace falta pasar por ninguna conversión de texto: el conversor de <task-card> solo entra en juego cuando el dato llega como atributo HTML, no cuando se asigna directamente desde JavaScript, exactamente la distinción explicada en la lección 03-03 (apartado 2) para los tipos Array y Object.

  1. <task-board>, completo: propiedades, contexto y carga inicial

<task-board> combina, en su constructor, el mixin del apartado 3, el proveedor de contexto de la lección 07-04, y la carga inicial con until de la lección 07-03:

// src/components/task-board.js
import { LitElement, html, css } from 'lit';
import { until } from 'lit/directives/until.js';
import { ContextProvider } from '@lit/context';
import { filtroContext } from '../context/filtro-context.js';
import { ConEstadoCarga } from '../mixins/con-estado-carga.js';
import { cargarTareas } from '../services/tareas-service.js';
import { estilosCompartidos } from '../styles/shared-styles.js';
import './task-filter.js';
import './task-list.js';

class TaskBoard extends ConEstadoCarga(LitElement) {
  // Módulo 6 (06-04): cada nivel que añade sus propias propiedades debe
  // propagar también las heredadas del mixin.
  static properties = {
    ...ConEstadoCarga(LitElement).properties,
    tareas: { type: Array },
  };

  constructor() {
    super();
    this.tareas = [];
    this._cargaCompletada = false;

    // Módulo 7 (07-04): proveedor del contexto de filtro compartido con
    // <task-filter> (lectura y escritura) y <task-list> (solo lectura).
    this._filtroProvider = new ContextProvider(this, {
      context: filtroContext,
      initialValue: {
        texto: '',
        estado: 'todas',
        actualizar: (cambios) => this._actualizarFiltro(cambios),
      },
    });

    // Módulo 7 (07-03): promesa creada una única vez en el constructor,
    // nunca dentro de render(), para no reiniciar la carga simulada.
    this._tareasTemplate = cargarTareas().then((tareas) => {
      this.tareas = tareas;
      this._cargaCompletada = true;
      return html`
        <task-list .tareas="${this.tareas}" @tarea-cambiada="${this.gestionarTareaCambiada}"></task-list>
      `;
    });
  }

  _actualizarFiltro(cambios) {
    this._filtroProvider.value = { ...this._filtroProvider.value, ...cambios };
  }

  // Módulo 5 (05-04): único punto de la aplicación que modifica `tareas`,
  // a partir del evento ya reenviado por <task-list> con `idTarea` incluido.
  gestionarTareaCambiada(event) {
    const { idTarea, nuevoEstado } = event.detail;
    this.tareas = this.tareas.map((tarea) =>
      tarea.id === idTarea ? { ...tarea, estado: nuevoEstado } : tarea
    );
  }

  renderEsqueleto() {
    return html`
      <div class="esqueleto" aria-busy="true" aria-label="Cargando tareas">
        <div class="esqueleto__linea"></div>
        <div class="esqueleto__linea"></div>
        <div class="esqueleto__linea"></div>
      </div>
    `;
  }

  // ...continúa en el apartado siguiente con render() y una pieza pendiente...
}

  1. Cerrando un hueco: mantener <task-list> sincronizada después de until

Aquí aparece la pieza que la lección 07-03 dejó, sin decirlo explícitamente, sin resolver del todo. until sustituye el esqueleto de carga por el resultado de this._tareasTemplate una única vez, en el instante en que la promesa resuelve; el <task-list> que aparece en ese momento queda con .tareas enlazada al valor de this.tareas que existía exactamente en ese instante. El problema es que <task-board> sigue reasignando this.tareas más adelante, cada vez que gestionarTareaCambiada procesa un cambio de estado —y esa reasignación posterior nunca vuelve a pasar por la promesa ya resuelta, porque until, una vez que un valor se ha resuelto, no vuelve a evaluar nada mientras la referencia de la promesa no cambie. Sin ningún ajuste adicional, <task-list> se quedaría mostrando, para siempre, la fotografía de tareas tomada justo después de la carga inicial.

La solución no necesita ningún concepto nuevo: updated(), el hook de la lección "Hooks Reactivos" (06-02), permite actualizar de forma imperativa la propiedad del elemento <task-list> ya insertado en el DOM, cada vez que this.tareas cambie después de la carga inicial:

  // Módulo 6 (06-02): updated() actúa después de que el DOM ya refleja el
  // cambio; aquí empuja el nuevo valor de `tareas` hacia el <task-list>
  // que `until` insertó una única vez, para que siga reflejando cambios
  // posteriores como los que llegan a través de gestionarTareaCambiada.
  updated(changedProperties) {
    if (changedProperties.has('tareas') && this._cargaCompletada) {
      const listaElemento = this.renderRoot.querySelector('task-list');
      if (listaElemento) {
        listaElemento.tareas = this.tareas;
      }
    }
  }

  render() {
    return html`
      <div class="tablero">
        <h1>TaskFlow</h1>
        <task-filter></task-filter>
        ${until(this._tareasTemplate, this.renderEsqueleto())}
      </div>
    `;
  }

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

      .esqueleto__linea {
        height: 3rem;
        margin-bottom: 0.5rem;
        border-radius: 4px;
        background: linear-gradient(90deg, #e0e0e0 25%, #ececec 50%, #e0e0e0 75%);
        background-size: 200% 100%;
        animation: pulso 1.4s ease-in-out infinite;
      }

      @keyframes pulso {
        0% { background-position: 200% 0; }
        100% { background-position: -200% 0; }
      }
    `,
  ];
}

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

El guardián changedProperties.has('tareas') && this._cargaCompletada evita dos casos que no interesa gestionar: antes de que la carga termine, <task-list> todavía no existe en el DOM (el esqueleto ocupa su lugar), así que renderRoot.querySelector('task-list') devolvería null; y la propia asignación inicial de this.tareas = tareas dentro del .then() ya llega a <task-list> de forma normal, a través de su propio .tareas="${this.tareas}" en el momento en que until inserta esa plantilla por primera vez, así que no hace falta ningún trabajo adicional de updated() para ese primer caso. Este ajuste no cambia nada de lo explicado en las lecciones 06-02 o 07-03 por separado; simplemente aplica el primero para completar un caso que el segundo, centrado únicamente en la carga inicial, no necesitaba resolver todavía.

  1. Tabla final: qué conecta a cada par de componentes

Par de componentes Mecanismo Dirección
<task-board><task-filter> Contexto de filtro (filtroContext), lectura y escritura vía actualizar Ambas direcciones
<task-board><task-list> Contexto de filtro (solo lectura) + propiedad .tareas Contexto ↑↓, propiedad ↓
<task-list><task-board> Evento tarea-cambiada, con idTarea añadido Evento ↑
<task-list><task-card> Propiedades por tarea (repeat + clave tarea.id) Propiedad ↓
<task-card><task-list> Evento tarea-cambiada original Evento ↑
<task-card><user-avatar> Atributo nombre + contenido distribuido (<slot>) Propiedad ↓ / slot

Errores Comunes y Consejos

  • Esperar que until reaccione a cambios posteriores de la propiedad usada dentro de su valor resuelto: como se ha explicado en el apartado 7, una vez que la promesa pasada a until resuelve, el valor mostrado queda fijado hasta que la propia promesa cambie de referencia; cualquier actualización posterior de una propiedad usada dentro de ese valor resuelto necesita un mecanismo aparte, como el updated() de este apartado.
  • Aplicar el ajuste de updated() sin comprobar que <task-list> ya existe: sin la comprobación if (listaElemento), cualquier cambio de tareas que llegara antes de que la carga inicial terminara (algo que no debería ocurrir en la práctica, pero que conviene proteger) lanzaría un error al intentar asignar una propiedad sobre null.
  • Olvidar propagar ConEstadoCarga(LitElement).properties al añadir tareas en <task-board>: exactamente el mismo riesgo señalado en la lección 06-04; sin el operador de propagación, la propiedad cargando del mixin dejaría de registrarse como reactiva.
  • Pasar fechaLimite como atributo de texto en lugar de como propiedad cuando el dato ya es un objeto Date: como se ha explicado en el apartado 5, .fechaLimite="${tarea.fechaLimite}" evita una conversión de ida y vuelta innecesaria; usar fecha-limite="${...}" obligaría a serializar el Date a texto solo para que el conversor de <task-card> lo reconstruyera de nuevo.

Ejercicios

  1. Añade a tareas-service.js una cuarta tarea con asignadoImagen no vacía, y comprueba, siguiendo el flujo de datos del apartado 5 y de la lección 04-04, que <user-avatar> la muestra como imagen en lugar de mostrar iniciales.
  2. Explica, basándote en el apartado 7 y en la lección 09-04 (apartado 7, sobre la limpieza simétrica de recursos), si updated() en <task-board> necesita algún tipo de limpieza en disconnectedCallback, o si, a diferencia de un setInterval, no arrastra ningún recurso pendiente que liberar.
  3. Un compañero de equipo propone eliminar el ConEstadoCarga(LitElement) de <task-board>, argumentando que la carga de tareas ya usa until y no el mixin. Basándote en el apartado 3, explica por qué conservarlo sigue siendo una decisión razonable aunque, en el estado actual del proyecto, ninguna funcionalidad visible lo esté usando todavía.

Soluciones

{
  id: 4,
  titulo: 'Actualizar la documentación de la API',
  estado: 'pendiente',
  prioridad: 1,
  urgente: false,
  asignadoA: 'Marc Puig',
  asignadoImagen: 'https://ejemplo.test/avatares/marc.jpg',
  fechaLimite: null,
},

Con esta tarea en el array, <task-list> la pasa como cualquier otra hacia una nueva <task-card>, cuyo renderAvatar() (lección 04-04, consolidado en 10-02) comprueba this.asignadoImagen y, al no estar vacío, distribuye un <img> dentro de <user-avatar> en lugar de dejar que calcule las iniciales de repuesto.

  1. updated() en <task-board> no arranca ningún recurso persistente (ni setInterval, ni ninguna suscripción activa): simplemente lee this.tareas y asigna una propiedad sobre un elemento ya existente, una operación puntual que termina en el mismo instante en que se ejecuta. A diferencia del temporizador de la lección 06-01 o del controlador de la lección 06-03, no hay ningún recurso "encendido" que deba "apagarse" en disconnectedCallback; por tanto, no hace falta ninguna limpieza simétrica adicional en este caso.
  2. El mixin sigue siendo una decisión razonable porque, como se explicó en la lección 06-04 y se recordó en el apartado 3 de esta lección, cargando y conIndicadorDeCarga no están pensados en exclusiva para la carga inicial de tareas (que, en efecto, usa until), sino para cualquier operación futura de <task-board> que necesite comunicar un estado de espera repetible, como guardar cambios o sincronizar con un servidor. Retirar el mixin ahorraría, como mucho, una línea de código; mantenerlo deja preparada, sin ningún coste real, la extensibilidad que el propio módulo 6 argumentó a su favor frente a until para ese tipo de operaciones.

Conclusión

Con esta lección, TaskFlow queda completamente ensamblado: el contexto de filtro, el controlador de proximidad de fecha, el mixin de estado de carga y el servicio simulado, todos consolidados en sus ficheros finales, y <task-board> y <task-list> completos, coordinando entre sí y con los tres componentes de la lección anterior exactamente como se trazó en el mapa de la primera lección de este módulo. En el camino se ha cerrado, con updated(), una pieza que la combinación de until con cambios posteriores de estado dejaba pendiente desde el módulo 7.

TaskFlow, como aplicación, está ya completo y funcional de principio a fin. Queda por resolver cómo se comprueba que sigue funcionando con pruebas automatizadas, cómo se empaqueta para su publicación o despliegue, y cómo se cierra el curso completo: esa es la tarea de la última lección, "Pruebas, Empaquetado y Despliegue Final".

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