The previous lesson consolidated the three components that display data: <user-avatar>, <task-card>, and <task-filter>. This lesson closes the TaskFlow tree with the two pieces that orchestrate it —<task-board> and <task-list>— together with all the shared infrastructure both use: the filter context, the reactive due-date-proximity controller, the loading-state mixin, and the service that simulates fetching tasks from an external source. Along the way, a piece left pending since module 7 is finally closed: how to make <task-list> keep receiving updated tasks after the initial load, resolved once with until, has already finished.
Contents
- What's left to consolidate
- The filter context, complete
- The controller and the mixin, complete
- The simulated loading service, complete
<task-list>, complete<task-board>, complete: properties, context, and initial load- Closing a gap: keeping
<task-list>in sync afteruntil - Final table: what connects each pair of components
- Towards the closing of the course
- What's left to consolidate
<task-board> and <task-list> are the two TaskFlow components with the most infrastructure pieces around them: a mixin, a context, a simulated service, and an asynchronous rendering directive, all of them explained in modules 6 and 7. Before writing the complete code for both, this lesson first gathers, one by one, the three supporting files that both take for granted: the filter context (src/context/filtro-context.js), the controller and the mixin (src/controllers/ and src/mixins/), and the loading service (src/services/tareas-service.js).
- The filter context, complete
The context file is, of the three, the smallest: a single key, with no default value embedded in the module itself (the initial value is declared where the ContextProvider is instantiated, inside <task-board>).
// src/context/filtro-context.js
import { createContext } from '@lit/context';
// Module 7 (07-04): unique context identifier, not the value itself.
export const filtroContext = createContext('filtro-tareas');
- The controller and the mixin, complete
ContadorTiempoRestanteController, used by <task-card> since module 6, hasn't changed a single line since its original lesson:
// src/controllers/contador-tiempo-restante-controller.js
// Module 6 (06-03): logic with its own state and lifecycle, reusable
// by any host that exposes a `fechaLimite` property.
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, the mixin that <task-board> will use in section 6, doesn't change since module 6 either:
// src/mixins/con-estado-carga.js
// Module 6 (06-04): behavior that integrates into the component's own API,
// not into a separate object like the previous controller.
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> still extends ConEstadoCarga(LitElement), just as decided in module 6, even though —as will be seen in section 6— the initial task load uses until instead of this.cargando; the mixin remains available, with its cargando property and its conIndicadorDeCarga method, for any future TaskFlow operation (saving changes, syncing with a server) that fits that pattern better, following exactly the criteria from the comparison table in the lesson "Asynchronous Rendering with until" (07-03, section 7).
- The simulated loading service, complete
The service from lesson 07-03 returned tasks with only four fields, enough at the time to explain until. With <task-card> already complete since the previous lesson, with support for an assigned person and a due date, this is the moment to also complete the sample data:
// src/services/tareas-service.js
// Module 7 (07-03): simulates an external data source with a delay.
// The sample data is completed here with the fields that <task-card>
// ended up needing in later modules (assignee, due date).
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);
});
}The first task, with a due date within the next twelve hours, guarantees that ContadorTiempoRestanteController and resaltarSiUrgente have, from the very first moment TaskFlow starts up, a real case to trigger without needing to wait or manually modify the data.
<task-list>, complete
<task-list>, complete<task-list> brings together the consumption of the filter context (07-04), the derived filtering (07-04, refined in 09-03), and forwarding the event up to <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 = [];
// Module 7 (07-04): consumer of the filter context published by <task-board>.
this._filtro = new ContextConsumer(this, { context: filtroContext, subscribe: true });
}
// Module 7 (07-04) / Module 9 (09-03): simple getter, no caching; for
// TaskFlow's data volume, moving it to willUpdate provides no
// perceptible improvement, as reasoned in the performance lesson.
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;
});
}
// Module 5 (05-04): doesn't handle the event, forwards it upward with
// idTarea explicitly added, because <task-board> has no
// access to the closure over `tarea.id` that does exist here inside the repeat.
reenviarTareaCambiada(idTarea, event) {
this.dispatchEvent(
new CustomEvent('tarea-cambiada', {
detail: { idTarea, nuevoEstado: event.detail.nuevoEstado },
bubbles: true,
composed: true,
})
);
}
// Module 2 (02-04) / Module 9 (09-03): repeat with a stable key, so that
// the filter can insert and remove cards without losing the internal
// state (like `expandida`) of the ones that remain visible.
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);A detail no previous lesson had shown explicitly: .fechaLimite="${tarea.fechaLimite}" uses dot-property binding, not the fecha-limite attribute with the converter from lesson 03-03. When the value is already, at its source, a real Date object (as here, coming from tareas-service.js), there's no need to go through any text conversion: <task-card>'s converter only comes into play when the data arrives as an HTML attribute, not when it's assigned directly from JavaScript, exactly the distinction explained in lesson 03-03 (section 2) for the Array and Object types.
<task-board>, complete: properties, context, and initial load
<task-board>, complete: properties, context, and initial load<task-board> combines, in its constructor, the mixin from section 3, the context provider from lesson 07-04, and the initial load with until from lesson 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) {
// Module 6 (06-04): every level that adds its own properties must
// also propagate the ones inherited from the mixin.
static properties = {
...ConEstadoCarga(LitElement).properties,
tareas: { type: Array },
};
constructor() {
super();
this.tareas = [];
this._cargaCompletada = false;
// Module 7 (07-04): provider of the filter context shared with
// <task-filter> (read and write) and <task-list> (read-only).
this._filtroProvider = new ContextProvider(this, {
context: filtroContext,
initialValue: {
texto: '',
estado: 'todas',
actualizar: (cambios) => this._actualizarFiltro(cambios),
},
});
// Module 7 (07-03): promise created only once in the constructor,
// never inside render(), so as not to restart the simulated load.
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 };
}
// Module 5 (05-04): the only point in the application that modifies `tareas`,
// based on the event already forwarded by <task-list> with `idTarea` included.
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>
`;
}
// ...continues in the next section with render() and one pending piece...
}
- Closing a gap: keeping
<task-list> in sync after until
<task-list> in sync after untilHere appears the piece that lesson 07-03 left, without saying so explicitly, not fully resolved. until replaces the loading skeleton with the result of this._tareasTemplate only once, at the instant the promise resolves; the <task-list> that appears at that moment ends up with .tareas bound to the value of this.tareas that existed at exactly that instant. The problem is that <task-board> keeps reassigning this.tareas later on, every time gestionarTareaCambiada processes a status change —and that later reassignment never again goes through the already-resolved promise, because until, once a value has resolved, doesn't evaluate anything again as long as the promise reference doesn't change. Without any further adjustment, <task-list> would keep showing, forever, the snapshot of tareas taken right after the initial load.
The solution doesn't need any new concept: updated(), the hook from the lesson "Reactive Hooks" (06-02), allows imperatively updating the property of the <task-list> element already inserted in the DOM, every time this.tareas changes after the initial load:
// Module 6 (06-02): updated() runs after the DOM already reflects the
// change; here it pushes the new value of `tareas` to the <task-list>
// that `until` inserted only once, so it keeps reflecting later
// changes like the ones arriving through 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);The guard changedProperties.has('tareas') && this._cargaCompletada avoids two cases that don't need handling: before the load finishes, <task-list> doesn't yet exist in the DOM (the skeleton takes its place), so renderRoot.querySelector('task-list') would return null; and the initial assignment itself of this.tareas = tareas inside the .then() already reaches <task-list> normally, through its own .tareas="${this.tareas}" at the moment until inserts that template for the first time, so no extra updated() work is needed for that first case. This adjustment doesn't change anything explained in lessons 06-02 or 07-03 separately; it simply applies the former to complete a case that the latter, focused solely on the initial load, didn't yet need to resolve.
- Final table: what connects each pair of components
| Component pair | Mechanism | Direction |
|---|---|---|
<task-board> ↔ <task-filter> |
Filter context (filtroContext), read and write via actualizar |
Both directions |
<task-board> ↔ <task-list> |
Filter context (read-only) + .tareas property |
Context ↑↓, property ↓ |
<task-list> → <task-board> |
tarea-cambiada event, with idTarea added |
Event ↑ |
<task-list> → <task-card> |
Per-task properties (repeat + tarea.id key) |
Property ↓ |
<task-card> → <task-list> |
Original tarea-cambiada event |
Event ↑ |
<task-card> → <user-avatar> |
nombre attribute + distributed content (<slot>) |
Property ↓ / slot |
Common Mistakes and Tips
- Expecting
untilto react to later changes of a property used inside its resolved value: as explained in section 7, once the promise passed tountilresolves, the displayed value stays fixed until the promise itself changes reference; any later update of a property used inside that resolved value needs a separate mechanism, like theupdated()from this section. - Applying the
updated()adjustment without checking that<task-list>already exists: without theif (listaElemento)check, any change totareasarriving before the initial load finished (something that shouldn't happen in practice, but is worth guarding against) would throw an error when trying to assign a property onnull. - Forgetting to propagate
ConEstadoCarga(LitElement).propertieswhen addingtareasto<task-board>: exactly the same risk pointed out in lesson 06-04; without the spread operator, the mixin'scargandoproperty would stop being registered as reactive. - Passing
fechaLimiteas a text attribute instead of as a property when the data is already aDateobject: as explained in section 5,.fechaLimite="${tarea.fechaLimite}"avoids an unnecessary round-trip conversion; usingfecha-limite="${...}"would force serializing theDateto text only for<task-card>'s converter to rebuild it again.
Exercises
- Add a fourth task to
tareas-service.jswith a non-emptyasignadoImagen, and check, following the data flow from section 5 and from lesson 04-04, that<user-avatar>displays it as an image instead of showing initials. - Explain, based on section 7 and on lesson 09-04 (section 7, about symmetric resource cleanup), whether
updated()in<task-board>needs any kind of cleanup indisconnectedCallback, or whether, unlike asetInterval, it carries no pending resource to release. - A teammate proposes removing
ConEstadoCarga(LitElement)from<task-board>, arguing that task loading already usesuntilinstead of the mixin. Based on section 3, explain why keeping it is still a reasonable decision even though, in the project's current state, no visible functionality is using it yet.
Solutions
{
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,
},With this task in the array, <task-list> passes it like any other to a new <task-card>, whose renderAvatar() (lesson 04-04, consolidated in 10-02) checks this.asignadoImagen and, since it's not empty, distributes an <img> inside <user-avatar> instead of letting it compute the fallback initials.
updated()in<task-board>doesn't start any persistent resource (nosetInterval, no active subscription): it simply readsthis.tareasand assigns a property on an already existing element, a one-off operation that ends the very instant it runs. Unlike the timer from lesson 06-01 or the controller from lesson 06-03, there's no "on" resource that needs to be "turned off" indisconnectedCallback; therefore, no additional symmetric cleanup is needed in this case.- The mixin remains a reasonable decision because, as explained in lesson 06-04 and recalled in section 3 of this lesson,
cargandoandconIndicadorDeCargaare not intended exclusively for the initial task load (which, indeed, usesuntil), but for any future<task-board>operation that needs to communicate a repeatable waiting state, such as saving changes or syncing with a server. Removing the mixin would save, at most, a single line of code; keeping it leaves ready, at no real cost, the extensibility that module 6 itself argued in its favor overuntilfor that kind of operation.
Conclusion
With this lesson, TaskFlow is fully assembled: the filter context, the due-date-proximity controller, the loading-state mixin, and the simulated service, all consolidated in their final files, and <task-board> and <task-list> complete, coordinating with each other and with the three components from the previous lesson exactly as mapped out in the first lesson of this module. Along the way, a piece that the combination of until with later state changes had left pending since module 7 has been closed, with updated().
TaskFlow, as an application, is now complete and functional from start to finish. What remains is to check that it still works with automated tests, how it's bundled for publication or deployment, and how the whole course is closed: that's the task of the final lesson, "Testing, Bundling and Final Deployment".
Lit Course
Module 1: Introduction to Lit and Web Components
- What are Web Components and why Lit?
- Setting Up the Development Environment
- Your First Lit Component
- Anatomy of a Lit Component
Module 2: Reactive Templates and Rendering
- Lit's Template Engine
- Expressions and Interpolation in Templates
- Conditional Rendering
- List Rendering
- The Rendering Cycle
Module 3: Reactive Properties and State
- Reactive Properties
- Internal State with @state
- Types of Properties and Custom Converters
- Attributes vs Properties and Reflection
Module 4: Styling Lit Components
- Encapsulated CSS with Shadow DOM
- Shared Styles Between Components
- Custom CSS Properties and Theming
- Slots and Styling Distributed Content
Module 5: Events and Component Communication
- Handling DOM Events in Templates
- Custom Events: Communication from Child to Parent
- Communication from Parent to Child with Properties
- Communication Patterns Between Sibling Components
Module 6: Lifecycle and Advanced Behavior
- Lifecycle Callbacks
- Reactive Hooks: willUpdate, updated, and firstUpdated
- Reactive Controllers
- Mixins and Composing Behavior
Module 7: Directives and Advanced Template Features
- Built-in Directives: classMap, styleMap and ifDefined
- Custom Directives
- Asynchronous Rendering with until
- Shared Context with @lit/context
Module 8: Integration, Interoperability and Deployment
- Using Lit Components in Plain HTML
- Integrating Lit with React, Vue, and Angular
- Server-Side Rendering with @lit-labs/ssr
- Bundling, Publishing, and TypeScript
Module 9: Testing and Best Practices
- Unit Tests with Web Test Runner
- Accessibility in Web Components
- Performance and Optimization
- Common Patterns and Anti-patterns
