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
- Qué queda por consolidar
- El contexto de filtro, completo
- El controlador y el mixin, completos
- El servicio de carga simulada, completo
<task-list>, completo<task-board>, completo: propiedades, contexto y carga inicial- Cerrando un hueco: mantener
<task-list>sincronizada después deuntil - Tabla final: qué conecta a cada par de componentes
- Hacia el cierre del curso
- 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).
- 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');
- 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).
- 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.
<task-list>, completo
<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.
<task-board>, completo: propiedades, contexto y carga inicial
<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...
}
- Cerrando un hueco: mantener
<task-list> sincronizada después de until
<task-list> sincronizada después de untilAquí 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.
- 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
untilreaccione 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 auntilresuelve, 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 elupdated()de este apartado. - Aplicar el ajuste de
updated()sin comprobar que<task-list>ya existe: sin la comprobaciónif (listaElemento), cualquier cambio detareasque 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 sobrenull. - Olvidar propagar
ConEstadoCarga(LitElement).propertiesal añadirtareasen<task-board>: exactamente el mismo riesgo señalado en la lección 06-04; sin el operador de propagación, la propiedadcargandodel mixin dejaría de registrarse como reactiva. - Pasar
fechaLimitecomo atributo de texto en lugar de como propiedad cuando el dato ya es un objetoDate: como se ha explicado en el apartado 5,.fechaLimite="${tarea.fechaLimite}"evita una conversión de ida y vuelta innecesaria; usarfecha-limite="${...}"obligaría a serializar elDatea texto solo para que el conversor de<task-card>lo reconstruyera de nuevo.
Ejercicios
- Añade a
tareas-service.jsuna cuarta tarea conasignadoImagenno 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. - 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 endisconnectedCallback, o si, a diferencia de unsetInterval, no arrastra ningún recurso pendiente que liberar. - Un compañero de equipo propone eliminar el
ConEstadoCarga(LitElement)de<task-board>, argumentando que la carga de tareas ya usauntily 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.
updated()en<task-board>no arranca ningún recurso persistente (nisetInterval, ni ninguna suscripción activa): simplemente leethis.tareasy 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" endisconnectedCallback; por tanto, no hace falta ninguna limpieza simétrica adicional en este caso.- 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,
cargandoyconIndicadorDeCargano están pensados en exclusiva para la carga inicial de tareas (que, en efecto, usauntil), 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 auntilpara 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
- ¿Qué son los Web Components y por qué Lit?
- Configuración del Entorno de Desarrollo
- Tu Primer Componente Lit
- Anatomía de un Componente Lit
Módulo 2: Plantillas Reactivas y Renderizado
- El Motor de Plantillas de Lit
- Expresiones e Interpolación en Plantillas
- Renderizado Condicional
- Renderizado de Listas
- El Ciclo de Renderizado
Módulo 3: Propiedades y Estado Reactivo
- Propiedades Reactivas
- Estado Interno con @state
- Tipos de Propiedades y Conversores Personalizados
- Atributos vs Propiedades y Reflexión
Módulo 4: Estilos en Componentes Lit
- CSS Encapsulado con Shadow DOM
- Estilos Compartidos entre Componentes
- Variables CSS Personalizadas y Theming
- Slots y Estilizado de Contenido Distribuido
Módulo 5: Eventos y Comunicación entre Componentes
- Manejo de Eventos DOM en Plantillas
- Eventos Personalizados: Comunicación Hijo a Padre
- Comunicación de Padre a Hijo con Propiedades
- Patrones de Comunicación entre Componentes Hermanos
Módulo 6: Ciclo de Vida y Comportamiento Avanzado
- Callbacks del Ciclo de Vida
- Hooks Reactivos: willUpdate, updated y firstUpdated
- Controladores Reactivos
- Mixins y Composición de Comportamiento
Módulo 7: Directivas y Funcionalidades Avanzadas de Plantillas
- Directivas Incorporadas: classMap, styleMap e ifDefined
- Directivas Personalizadas
- Renderizado Asíncrono con until
- Contexto Compartido con @lit/context
Módulo 8: Integración, Interoperabilidad y Despliegue
- Usar Componentes Lit en HTML Plano
- Integrar Lit con React, Vue y Angular
- Renderizado en el Servidor con @lit-labs/ssr
- Empaquetado, Publicación y TypeScript
Módulo 9: Pruebas y Buenas Prácticas
- Pruebas Unitarias con Web Test Runner
- Accesibilidad en Web Components
- Rendimiento y Optimización
- Patrones y Anti-patrones Comunes
