La lliçó anterior ha consolidat els tres components que mostren dades: <user-avatar>, <task-card> i <task-filter>. Aquesta lliçó tanca l'arbre de TaskFlow amb les dues peces que l'orquestren —<task-board> i <task-list>— juntament amb tota la infraestructura compartida que ambdues usen: el context de filtre, el controlador reactiu de proximitat de data, el mixin d'estat de càrrega, i el servei que simula l'obtenció de tasques des d'una font externa. Pel camí es tanca, finalment, una peça que va quedar pendent des del mòdul 7: com fer que <task-list> continuï rebent tasques actualitzades després que la càrrega inicial, resolta una única vegada amb until, ja hagi acabat.
Contingut
- Què queda per consolidar
- El context de filtre, complet
- El controlador i el mixin, complets
- El servei de càrrega simulada, complet
<task-list>, complet<task-board>, complet: propietats, context i càrrega inicial- Tancant un buit: mantenir
<task-list>sincronitzada després deuntil - Taula final: què connecta cada parell de components
- Cap al tancament del curs
- Què queda per consolidar
<task-board> i <task-list> són els dos components de TaskFlow amb més peces d'infraestructura al voltant: un mixin, un context, un servei simulat i una directiva de renderitzat asíncron, totes explicades als mòduls 6 i 7. Abans d'escriure el codi complet d'ambdós, aquesta lliçó reuneix primer, un a un, els tres fitxers de suport que tots dos donen per fet: el context de filtre (src/context/filtro-context.js), el controlador i el mixin (src/controllers/ i src/mixins/), i el servei de càrrega (src/services/tareas-service.js).
- El context de filtre, complet
El fitxer de context és, dels tres, el més petit: una única clau, sense cap valor per defecte incrustat en el propi mòdul (el valor inicial es declara on s'instancia el ContextProvider, dins de <task-board>).
// src/context/filtro-context.js
import { createContext } from '@lit/context';
// Mòdul 7 (07-04): identificador únic de context, no el valor en si.
export const filtroContext = createContext('filtro-tareas');
- El controlador i el mixin, complets
ContadorTiempoRestanteController, utilitzat per <task-card> des del mòdul 6, no ha canviat ni una línia des de la seva lliçó d'origen:
// src/controllers/contador-tiempo-restante-controller.js
// Mòdul 6 (06-03): lògica amb estat propi i cicle de vida, reutilitzable
// per qualsevol host que exposi una propietat `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> utilitzarà a l'apartat 6, tampoc canvia des del mòdul 6:
// src/mixins/con-estado-carga.js
// Mòdul 6 (06-04): comportament que s'integra en la pròpia API del
// component, no en un objecte separat com 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> continua estenent ConEstadoCarga(LitElement), tal com es va decidir al mòdul 6, encara que —com es veurà a l'apartat 6— la càrrega inicial de tasques utilitzi until en lloc de this.cargando; el mixin queda disponible, amb la seva propietat cargando i el seu mètode conIndicadorDeCarga, per a qualsevol operació futura de TaskFlow (desar canvis, sincronitzar amb un servidor) que encaixi millor amb aquest patró, seguint exactament el criteri de la taula comparativa de la lliçó "Renderitzat Asíncron amb until" (07-03, apartat 7).
- El servei de càrrega simulada, complet
El servei de la lliçó 07-03 retornava tasques amb només quatre camps, suficients en aquell moment per explicar until. Amb <task-card> ja complet des de la lliçó anterior, amb suport per a persona assignada i data límit, aquest és el moment de completar també les dades d'exemple:
// src/services/tareas-service.js
// Mòdul 7 (07-03): simula una font de dades externa amb un retard.
// Les dades d'exemple es completen aquí amb els camps que <task-card>
// va acabar necessitant en mòduls posteriors (assignat, data límit).
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 tasca, amb una data límit dins de les properes dotze hores, garanteix que ContadorTiempoRestanteController i resaltarSiUrgente tinguin, des del primer moment en què TaskFlow arrenca, un cas real que activar sense necessitat d'esperar ni de modificar les dades a mà.
<task-list>, complet
<task-list>, complet<task-list> reuneix el consum del context de filtre (07-04), el filtratge derivat (07-04, refinat a 09-03) i el reenviament de l'esdeveniment cap a <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òdul 7 (07-04): consumidor del context de filtre publicat per <task-board>.
this._filtro = new ContextConsumer(this, { context: filtroContext, subscribe: true });
}
// Mòdul 7 (07-04) / Mòdul 9 (09-03): getter simple, sense memòria cau; per al
// volum de dades de TaskFlow, moure'l a willUpdate no aporta cap
// millora perceptible, tal com es va raonar a la lliçó de rendiment.
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òdul 5 (05-04): no gestiona l'esdeveniment, el reenvia cap amunt amb
// l'idTarea afegit explícitament, perquè <task-board> no té
// accés al tancament sobre `tarea.id` que sí que existeix aquí dins del repeat.
reenviarTareaCambiada(idTarea, event) {
this.dispatchEvent(
new CustomEvent('tarea-cambiada', {
detail: { idTarea, nuevoEstado: event.detail.nuevoEstado },
bubbles: true,
composed: true,
})
);
}
// Mòdul 2 (02-04) / Mòdul 9 (09-03): repeat amb clau estable, perquè
// el filtre pugui inserir i eliminar targetes sense perdre l'estat
// intern (com `expandida`) de les que romanen 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 detall que cap lliçó anterior havia mostrat de forma explícita: .fechaLimite="${tarea.fechaLimite}" utilitza el binding de propietat amb punt, no l'atribut fecha-limite amb el conversor de la lliçó 03-03. Quan el valor ja és, en origen, un objecte Date real (com aquí, procedent de tareas-service.js), no cal passar per cap conversió de text: el conversor de <task-card> només entra en joc quan la dada arriba com a atribut HTML, no quan s'assigna directament des de JavaScript, exactament la distinció explicada a la lliçó 03-03 (apartat 2) per als tipus Array i Object.
<task-board>, complet: propietats, context i càrrega inicial
<task-board>, complet: propietats, context i càrrega inicial<task-board> combina, en el seu constructor, el mixin de l'apartat 3, el proveïdor de context de la lliçó 07-04, i la càrrega inicial amb until de la lliçó 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òdul 6 (06-04): cada nivell que afegeix les seves pròpies propietats ha
// de propagar també les heretades del mixin.
static properties = {
...ConEstadoCarga(LitElement).properties,
tareas: { type: Array },
};
constructor() {
super();
this.tareas = [];
this._cargaCompletada = false;
// Mòdul 7 (07-04): proveïdor del context de filtre compartit amb
// <task-filter> (lectura i escriptura) i <task-list> (només lectura).
this._filtroProvider = new ContextProvider(this, {
context: filtroContext,
initialValue: {
texto: '',
estado: 'todas',
actualizar: (cambios) => this._actualizarFiltro(cambios),
},
});
// Mòdul 7 (07-03): promesa creada una única vegada en el constructor,
// mai dins de render(), per no reiniciar la càrrega 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òdul 5 (05-04): únic punt de l'aplicació que modifica `tareas`,
// a partir de l'esdeveniment ja reenviat per <task-list> amb `idTarea` inclòs.
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>
`;
}
// ...continua a l'apartat següent amb render() i una peça pendent...
}
- Tancant un buit: mantenir
<task-list> sincronitzada després de until
<task-list> sincronitzada després de untilAquí apareix la peça que la lliçó 07-03 va deixar, sense dir-ho explícitament, sense resoldre del tot. until substitueix l'esquelet de càrrega pel resultat de this._tareasTemplate una única vegada, en l'instant en què la promesa es resol; el <task-list> que apareix en aquell moment queda amb .tareas enllaçada al valor de this.tareas que existia exactament en aquell instant. El problema és que <task-board> continua reassignant this.tareas més endavant, cada vegada que gestionarTareaCambiada processa un canvi d'estat —i aquesta reassignació posterior mai no torna a passar per la promesa ja resolta, perquè until, un cop un valor s'ha resolt, no torna a avaluar res mentre la referència de la promesa no canviï. Sense cap ajust addicional, <task-list> es quedaria mostrant, per sempre, la fotografia de tareas presa just després de la càrrega inicial.
La solució no necessita cap concepte nou: updated(), el hook de la lliçó "Hooks Reactius" (06-02), permet actualitzar de forma imperativa la propietat de l'element <task-list> ja inserit al DOM, cada vegada que this.tareas canviï després de la càrrega inicial:
// Mòdul 6 (06-02): updated() actua després que el DOM ja reflecteixi el
// canvi; aquí empeny el nou valor de `tareas` cap al <task-list>
// que `until` va inserir una única vegada, perquè continuï reflectint canvis
// posteriors com els que arriben mitjançant 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à changedProperties.has('tareas') && this._cargaCompletada evita dos casos que no interessa gestionar: abans que la càrrega acabi, <task-list> encara no existeix al DOM (l'esquelet ocupa el seu lloc), de manera que renderRoot.querySelector('task-list') retornaria null; i la pròpia assignació inicial de this.tareas = tareas dins del .then() ja arriba a <task-list> de forma normal, mitjançant el seu propi .tareas="${this.tareas}" en el moment en què until insereix aquesta plantilla per primera vegada, de manera que no cal cap feina addicional d'updated() per a aquest primer cas. Aquest ajust no canvia res del que s'ha explicat a les lliçons 06-02 o 07-03 per separat; simplement aplica la primera per completar un cas que la segona, centrada únicament en la càrrega inicial, encara no necessitava resoldre.
- Taula final: què connecta cada parell de components
| Parell de components | Mecanisme | Direcció |
|---|---|---|
<task-board> ↔ <task-filter> |
Context de filtre (filtroContext), lectura i escriptura via actualizar |
Ambdues direccions |
<task-board> ↔ <task-list> |
Context de filtre (només lectura) + propietat .tareas |
Context ↑↓, propietat ↓ |
<task-list> → <task-board> |
Esdeveniment tarea-cambiada, amb idTarea afegit |
Esdeveniment ↑ |
<task-list> → <task-card> |
Propietats per tasca (repeat + clau tarea.id) |
Propietat ↓ |
<task-card> → <task-list> |
Esdeveniment tarea-cambiada original |
Esdeveniment ↑ |
<task-card> → <user-avatar> |
Atribut nombre + contingut distribuït (<slot>) |
Propietat ↓ / slot |
Errors Comuns i Consells
- Esperar que
untilreaccioni a canvis posteriors de la propietat utilitzada dins del seu valor resolt: com s'ha explicat a l'apartat 7, un cop la promesa passada auntiles resol, el valor mostrat queda fixat fins que la pròpia promesa canviï de referència; qualsevol actualització posterior d'una propietat utilitzada dins d'aquell valor resolt necessita un mecanisme a part, com l'updated()d'aquest apartat. - Aplicar l'ajust d'
updated()sense comprovar que<task-list>ja existeix: sense la comprovacióif (listaElemento), qualsevol canvi detareasque arribés abans que la càrrega inicial acabés (una cosa que no hauria de passar a la pràctica, però que convé protegir) llançaria un error en intentar assignar una propietat sobrenull. - Oblidar propagar
ConEstadoCarga(LitElement).propertiesen afegirtareasa<task-board>: exactament el mateix risc assenyalat a la lliçó 06-04; sense l'operador de propagació, la propietatcargandodel mixin deixaria de registrar-se com a reactiva. - Passar
fechaLimitecom a atribut de text en lloc de com a propietat quan la dada ja és un objecteDate: com s'ha explicat a l'apartat 5,.fechaLimite="${tarea.fechaLimite}"evita una conversió d'anada i tornada innecessària; utilitzarfecha-limite="${...}"obligaria a serialitzar elDatea text només perquè el conversor de<task-card>el reconstruís de nou.
Exercicis
- Afegeix a
tareas-service.jsuna quarta tasca ambasignadoImagenno buida, i comprova, seguint el flux de dades de l'apartat 5 i de la lliçó 04-04, que<user-avatar>la mostra com a imatge en lloc de mostrar inicials. - Explica, basant-te en l'apartat 7 i en la lliçó 09-04 (apartat 7, sobre la neteja simètrica de recursos), si
updated()a<task-board>necessita algun tipus de neteja adisconnectedCallback, o si, a diferència d'unsetInterval, no arrossega cap recurs pendent d'alliberar. - Un company d'equip proposa eliminar el
ConEstadoCarga(LitElement)de<task-board>, argumentant que la càrrega de tasques ja utilitzauntili no el mixin. Basant-te en l'apartat 3, explica per què conservar-lo continua sent una decisió raonable encara que, en l'estat actual del projecte, cap funcionalitat visible l'estigui utilitzant encara.
Solucions
{
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,
},Amb aquesta tasca a l'array, <task-list> la passa com qualsevol altra cap a una nova <task-card>, el renderAvatar() de la qual (lliçó 04-04, consolidat a 10-02) comprova this.asignadoImagen i, en no estar buit, distribueix un <img> dins de <user-avatar> en lloc de deixar que calculi les inicials de recanvi.
updated()a<task-board>no engega cap recurs persistent (nisetInterval, ni cap subscripció activa): simplement llegeixthis.tareasi assigna una propietat sobre un element ja existent, una operació puntual que acaba en el mateix instant en què s'executa. A diferència del temporitzador de la lliçó 06-01 o del controlador de la lliçó 06-03, no hi ha cap recurs "encès" que hagi de "apagar-se" adisconnectedCallback; per tant, no cal cap neteja simètrica addicional en aquest cas.- El mixin continua sent una decisió raonable perquè, com es va explicar a la lliçó 06-04 i es va recordar a l'apartat 3 d'aquesta lliçó,
cargandoiconIndicadorDeCargano estan pensats en exclusiva per a la càrrega inicial de tasques (que, efectivament, utilitzauntil), sinó per a qualsevol operació futura de<task-board>que necessiti comunicar un estat d'espera repetible, com desar canvis o sincronitzar amb un servidor. Retirar el mixin estalviaria, com a molt, una línia de codi; mantenir-lo deixa preparada, sense cap cost real, l'extensibilitat que el propi mòdul 6 va argumentar al seu favor davant d'untilper a aquest tipus d'operacions.
Conclusió
Amb aquesta lliçó, TaskFlow queda completament muntat: el context de filtre, el controlador de proximitat de data, el mixin d'estat de càrrega i el servei simulat, tots consolidats en els seus fitxers finals, i <task-board> i <task-list> complets, coordinant-se entre si i amb els tres components de la lliçó anterior exactament com es va traçar en el mapa de la primera lliçó d'aquest mòdul. Pel camí s'ha tancat, amb updated(), una peça que la combinació d'until amb canvis posteriors d'estat deixava pendent des del mòdul 7.
TaskFlow, com a aplicació, ja està completa i funcional de principi a fi. Queda per resoldre com es comprova que continua funcionant amb proves automatitzades, com s'empaqueta per a la seva publicació o desplegament, i com es tanca el curs complet: aquesta és la tasca de l'última lliçó, "Proves, Empaquetatge i Desplegament Final".
Curs de Lit
Mòdul 1: Introducció a Lit i Web Components
- Què són els Web Components i per què Lit?
- Configuració de l'Entorn de Desenvolupament
- El Teu Primer Component Lit
- Anatomia d'un Component Lit
Mòdul 2: Plantilles Reactives i Renderitzat
- El Motor de Plantilles de Lit
- Expressions i Interpolació en Plantilles
- Renderitzat Condicional
- Renderitzat de Llistes
- El Cicle de Renderitzat
Mòdul 3: Propietats i Estat Reactiu
- Propietats Reactives
- Estat Intern amb @state
- Tipus de Propietats i Conversors Personalitzats
- Atributs vs Propietats i Reflexió
Mòdul 4: Estils en Components Lit
- CSS Encapsulat amb Shadow DOM
- Estils Compartits entre Components
- Variables CSS Personalitzades i Theming
- Slots i Estilitzat de Contingut Distribuït
Mòdul 5: Esdeveniments i Comunicació entre Components
- Gestió d'Esdeveniments DOM en Plantilles
- Esdeveniments Personalitzats: Comunicació de Fill a Pare
- Comunicació de Pare a Fill amb Propietats
- Patrons de Comunicació entre Components Germans
Mòdul 6: Cicle de Vida i Comportament Avançat
- Callbacks del Cicle de Vida
- Hooks Reactius: willUpdate, updated i firstUpdated
- Controladors Reactius
- Mixins i Composició de Comportament
Mòdul 7: Directives i Funcionalitats Avançades de Plantilles
- Directives Incorporades: classMap, styleMap i ifDefined
- Directives Personalitzades
- Renderitzat Asíncron amb until
- Context Compartit amb @lit/context
Mòdul 8: Integració, Interoperabilitat i Desplegament
- Utilitzar Components Lit en HTML Pla
- Integrar Lit amb React, Vue i Angular
- Renderitzat al Servidor amb @lit-labs/ssr
- Empaquetatge, Publicació i TypeScript
Mòdul 9: Proves i Bones Pràctiques
- Proves Unitàries amb Web Test Runner
- Accessibilitat en Web Components
- Rendiment i Optimització
- Patrons i Antipatrons Comuns
