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

  1. What's left to consolidate
  2. The filter context, complete
  3. The controller and the mixin, complete
  4. The simulated loading service, complete
  5. <task-list>, complete
  6. <task-board>, complete: properties, context, and initial load
  7. Closing a gap: keeping <task-list> in sync after until
  8. Final table: what connects each pair of components
  9. Towards the closing of the course

  1. 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).

  1. 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');

  1. 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).

  1. 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.

  1. <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.

  1. <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...
}

  1. Closing a gap: keeping <task-list> in sync after until

Here 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.

  1. 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 until to react to later changes of a property used inside its resolved value: as explained in section 7, once the promise passed to until resolves, 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 the updated() from this section.
  • Applying the updated() adjustment without checking that <task-list> already exists: without the if (listaElemento) check, any change to tareas arriving 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 on null.
  • Forgetting to propagate ConEstadoCarga(LitElement).properties when adding tareas to <task-board>: exactly the same risk pointed out in lesson 06-04; without the spread operator, the mixin's cargando property would stop being registered as reactive.
  • Passing fechaLimite as a text attribute instead of as a property when the data is already a Date object: as explained in section 5, .fechaLimite="${tarea.fechaLimite}" avoids an unnecessary round-trip conversion; using fecha-limite="${...}" would force serializing the Date to text only for <task-card>'s converter to rebuild it again.

Exercises

  1. Add a fourth task to tareas-service.js with a non-empty asignadoImagen, 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.
  2. 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 in disconnectedCallback, or whether, unlike a setInterval, it carries no pending resource to release.
  3. A teammate proposes removing ConEstadoCarga(LitElement) from <task-board>, arguing that task loading already uses until instead 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.

  1. updated() in <task-board> doesn't start any persistent resource (no setInterval, no active subscription): it simply reads this.tareas and 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" in disconnectedCallback; therefore, no additional symmetric cleanup is needed in this case.
  2. The mixin remains a reasonable decision because, as explained in lesson 06-04 and recalled in section 3 of this lesson, cargando and conIndicadorDeCarga are not intended exclusively for the initial task load (which, indeed, uses until), 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 over until for 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

Module 2: Reactive Templates and Rendering

Module 3: Reactive Properties and State

Module 4: Styling Lit Components

Module 5: Events and Component Communication

Module 6: Lifecycle and Advanced Behavior

Module 7: Directives and Advanced Template Features

Module 8: Integration, Interoperability and Deployment

Module 9: Testing and Best Practices

Module 10: Project: Building TaskFlow

© Copyright 2026. All rights reserved