Until now, TaskFlow has always worked with data already available in advance: <task-board>'s tareas array has been initialized directly in the constructor, with hand-written example values, ready to render from the very first moment. No real project works like that: the tasks in a management application would arrive from an API, a local database, or any other source that takes a while —usually indeterminate— to respond. This lesson solves that problem with the until directive, and takes the opportunity to connect that solution with the ConEstadoCarga mixin from module 6, which already pointed in the same direction with a different tool.
Contents
- The problem: showing data that hasn't arrived yet
- The
untildirective and its basic signature - Simulating a real load:
cargarTareas() - Applying
untilto<task-board>with a loading skeleton - A common mistake: creating the promise inside
render() untilwith multiple values: priority by positionuntilversusConEstadoCarga: when each one is a better fit
- The problem: showing data that hasn't arrived yet
render(), as studied since module 2, must always run synchronously: it receives the component's current state and immediately returns a template, without waiting for anything. This restriction isn't negotiable and has no special escape route: render() can never be an async function, nor can it directly return a Promise for Lit to "wait" for before painting anything on screen.
The problem, then, is obvious: if <task-board> needed to load its tasks from an asynchronous function (cargarTareas(), which takes a while to resolve), what should render() return while that promise is still pending? Without any additional tool, the only option would be to resort to some variant of what was already seen in module 6: store the result in a state property (this.tareas), initialize it empty, and update it when the promise resolves, letting the new value trigger a new render() through the usual reactive property mechanism. That solution works, and is in fact the one that has been used implicitly so far without naming it; the ConEstadoCarga mixin from lesson 06-04 added, on top of that same base, an explicit cargando property to show a notice while the wait lasts. This lesson presents a more declarative alternative, designed specifically for this problem: expressing the wait directly inside the template, without needing an intermediate state property dedicated solely to knowing whether something is still loading.
- The
until directive and its basic signature
until directive and its basic signatureuntil, in its simplest use, takes two arguments: a Promise and a fallback value (usually another html template, though it can be any renderable value). While the promise hasn't resolved, until shows the fallback content; the instant the promise resolves, until automatically replaces that content with the resolved value, without the component using it having to manually manage any state property to know at what moment that replacement happens. Internally, until is nothing more than another custom directive, built with exactly the same pieces —directive() and Directive— presented in the previous lesson: it keeps its own internal state (which value to show at each moment) and uses part to update the DOM position when the promise resolves, without its user needing to know anything about that internal detail to make use of it.
- Simulating a real load:
cargarTareas()
cargarTareas()In order to apply until to a real TaskFlow case, something that simulates waiting on an external data source is needed first. Without getting into real network requests yet (that belongs to module 8), a function that returns a Promise resolving after a brief simulated delay with setTimeout is enough:
// src/services/tareas-service.js
export function cargarTareas() {
return new Promise((resolve) => {
setTimeout(() => {
resolve([
{ id: 't1', titulo: 'Diseñar la base de datos', estado: 'hecha', prioridad: 2 },
{ id: 't2', titulo: 'Implementar autenticación', estado: 'progreso', prioridad: 3 },
{ id: 't3', titulo: 'Escribir pruebas de integración', estado: 'pendiente', prioridad: 1 },
]);
}, 1200);
});
}This function isn't part of the <task-board> component itself; it lives in a separate service module, following the same idea of separation of concerns that has already appeared at other points in the course (the controllers in module 6, for example): <task-board> doesn't need to know how the tasks are obtained, only that cargarTareas() returns a promise that eventually resolves with an array of tasks.
- Applying
until to <task-board> with a loading skeleton
until to <task-board> with a loading skeletonWith the simulation ready, <task-board> stores the promise returned by cargarTareas() exactly once, and transforms it with .then() into a promise that resolves directly to a template, ready to be passed to until:
// src/components/task-board.js
import { LitElement, html, css } from 'lit';
import { until } from 'lit/directives/until.js';
import { ConEstadoCarga } from '../mixins/con-estado-carga.js';
import { cargarTareas } from '../services/tareas-service.js';
import './task-list.js';
class TaskBoard extends ConEstadoCarga(LitElement) {
static properties = {
...ConEstadoCarga(LitElement).properties,
tareas: { type: Array },
};
constructor() {
super();
this.tareas = [];
this._tareasTemplate = cargarTareas().then((tareas) => {
this.tareas = tareas;
return html`
<task-list .tareas="${tareas}" @tarea-cambiada="${this.gestionarTareaCambiada}"></task-list>
`;
});
}
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>
`;
}
render() {
return html`
<div class="tablero">
<h1>TaskFlow</h1>
${until(this._tareasTemplate, this.renderEsqueleto())}
</div>
`;
}
static styles = css`
.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 central point of the code above is this._tareasTemplate, built in the constructor with cargarTareas().then((tareas) => { ... }): instead of storing cargarTareas()'s "raw" promise (which would resolve with a data array), a .then() is chained that does two things at once —updating this.tareas as a side effect, in case some other part of the component needs to read that array directly, and already returning an html template with <task-list> fully assembled—. The result, this._tareasTemplate, is a promise that resolves directly to something renderable, exactly what until expects as its first argument.
In render(), until(this._tareasTemplate, this.renderEsqueleto()) shows the loading skeleton (three bars with a pulse animation, defined in static styles) while the promise remains pending, and automatically replaces it with <task-list> the instant cargarTareas() resolves, without <task-board> having had to declare any cargando property for this particular operation.
- A common mistake: creating the promise inside
render()
render()The most important detail of the entire example above, easy to overlook, is that this._tareasTemplate is created exactly once, in the constructor, not inside render(). Writing this instead would be a serious mistake:
// Incorrect: don't do this
render() {
return html`
<div class="tablero">
<h1>TaskFlow</h1>
${until(cargarTareas().then((tareas) => html`<task-list .tareas="${tareas}">...</task-list>`), this.renderEsqueleto())}
</div>
`;
}render() can run many times during a component's life, every time any reactive property changes (as explained in lesson 02-05). If cargarTareas() were called inside render(), every new execution of render() —even one triggered by a change completely unrelated to loading tasks— would fire off a new call to the function, starting a new network simulation from scratch and showing the loading skeleton again while that new promise is pending, in a loop of flashes that would never fully settle. That's why this._tareasTemplate is computed exactly once, in the constructor (the right place for any operation that must happen exactly once in the component's life, as already seen with reactive controllers and mixins in module 6), and render() simply reads that same, already stable, reference on every execution.
until with multiple values: priority by position
until with multiple values: priority by positionBesides the two-argument form already seen, until accepts any number of values, not necessarily all promises:
With multiple arguments, until treats them with a strict priority order based on their position, not on the order in which they resolve: while none has resolved yet, the last argument is shown (the lowest priority, usually a synchronous value, not a promise); as soon as any of the preceding arguments resolves, its value is shown, but as soon as a higher-priority one (further to the left) resolves, until switches to showing that one instead, and never goes back to lower-priority ones even if they update their value later on. This makes it possible to build progressive loading with more than one level of detail —for example, a quick summary computed in the browser itself while waiting for the full response from a slower source—, something TaskFlow doesn't need yet with a single data source like cargarTareas(), but which is worth knowing in order to recognize the pattern if it appears in third-party code or in future needs of the project.
until versus ConEstadoCarga: when each one is a better fit
until versus ConEstadoCarga: when each one is a better fitWith both tools now available for the same general problem —showing something while an asynchronous operation is awaited—, it's worth settling the criterion for choosing between them in each future TaskFlow situation:
| Criterion | until |
ConEstadoCarga (mixin, lesson 06-04) |
|---|---|---|
| Where the waiting logic lives | Declared directly in the template, at the exact point where it's needed | In a cargando property of the component itself, managed explicitly |
| Is the loading state visible outside that template position? | No, not directly; it only affects that specific expression | Yes: this.cargando can be read and used anywhere in the component (for example, to disable a button) |
| Does it fit well with a value awaited once? | Yes, it's its main use case | Also, but requires manually managing the change from true to false |
| Does it fit well with an operation repeated many times (saving, retrying)? | Worse: each new operation requires a new promise and, with care, showing a "pending" state again | Better: it's enough to toggle this.cargando between true and false as many times as needed |
| Example from this course | Initial loading of tasks in <task-board>, a single value awaited once |
Any future operation that needs to communicate its loading state to several parts of the component, or that repeats several times |
The underlying criterion is that until is a declarative solution, tied to a single template position and a single specific promise, ideal for this lesson's case: a value that's awaited once and that, the moment it arrives, cleanly replaces its fallback content. ConEstadoCarga, on the other hand, exposes its state as a normal component property, more flexible for operations that repeat over time or that need to communicate their loading state beyond a single position in a single template. Both techniques are complementary, not mutually exclusive: <task-board> keeps extending ConEstadoCarga(LitElement) in the section 4 example, available for any future TaskFlow operation (like saving changes) that does fit better with that pattern, while the initial loading of tasks, being a one-time operation, uses until instead.
Common Mistakes and Tips
- Creating the promise inside
render()instead of theconstructor: as explained in section 5, this restarts the asynchronous operation on every render, causing repeated flashes of the fallback content and, in a real case with a network request, unnecessary duplicate requests. - Expecting
untilto work with anasyncfunction likerender():render()can never be declaredasyncnor directly return aPromise;untilis precisely the mechanism that makes it possible to keeprender()synchronous while representing, within a specific template position, the result of an operation that is indeed asynchronous. - Forgetting the fallback content: if
until(promesa)is called with a single argument, with no fallback value, the corresponding template position simply stays empty while the promise is pending, which can result in a confusing interface (with no indication that something is loading) if that isn't intentional. - Confusing the priority by position from section 6 with priority by resolution order: with multiple arguments,
untildoesn't always show "whatever resolved most recently", but the highest-priority argument (furthest to the left) that has already resolved at that moment; a lower-priority promise that resolves later doesn't replace a higher-priority one that had already resolved before.
Exercises
- Modify
cargarTareas()so that, with a 20% probability (for example,Math.random() < 0.2), it rejects the promise instead of resolving it, simulating a network failure. Add a.catch()to_tareasTemplatethat returns an error template ("No se han podido cargar las tareas") instead of letting the rejection propagate unhandled. - Explain, based on section 5, what difference there would be between storing
this._tareasTemplate = cargarTareas().then(...)in theconstructor(as in section 4) and storing it inconnectedCallback()instead. Is it still correct, or does something relevant change? - Revisit the table from section 7 and decide, reasoning through your answer, which technique you would use for a future TaskFlow feature that lets the user press a "Guardar cambios" button on
<task-card>, showing an indicator while the operation is in progress and allowing the user to repeat it multiple times.
Solutions
export function cargarTareas() {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (Math.random() < 0.2) {
reject(new Error('Fallo simulado de red'));
return;
}
resolve([
{ id: 't1', titulo: 'Diseñar la base de datos', estado: 'hecha', prioridad: 2 },
{ id: 't2', titulo: 'Implementar autenticación', estado: 'progreso', prioridad: 3 },
{ id: 't3', titulo: 'Escribir pruebas de integración', estado: 'pendiente', prioridad: 1 },
]);
}, 1200);
});
}this._tareasTemplate = cargarTareas()
.then((tareas) => {
this.tareas = tareas;
return html`<task-list .tareas="${tareas}" @tarea-cambiada="${this.gestionarTareaCambiada}"></task-list>`;
})
.catch(() => html`<p class="error">No se han podido cargar las tareas.</p>`);connectedCallback()can be called more than once during a component's life, if the element disconnects from the DOM and reconnects later on (for example, if it's moved from one container to another), as explained in lesson 06-01. Storingthis._tareasTemplatethere, instead of in theconstructor, would start a new call tocargarTareas()every time the component reconnects, exactly the same problem pointed out in section 5 forrender(), though at a much lower frequency. Theconstructoris preferable here precisely because it's guaranteed to run exactly once in the entire life of the instance.- For "Guardar cambios",
ConEstadoCargais the better fit (or an equivalent pattern with aguardandoproperty of<task-card>'s own), notuntil: the operation repeats every time the user presses the button, not just once in the component's life, anduntilisn't designed to cleanly replace its promise with a new one repeatedly. With a state property (guardando, manually toggled totruewhen starting the request and tofalsewhen finishing, whether successfully or with an error), the button can be disabled while the operation is in progress and re-enabled when it finishes, allowing as many attempts as the user needs, which fits exactly with the "operation repeated many times" row criterion from the table in section 7.
Conclusion
This lesson has closed the asynchronous rendering problem with the until directive, applied to the initial loading of tasks in <task-board>: a promise created exactly once in the constructor, transformed with .then() into a promise that resolves directly to a template, and combined with a loading skeleton as fallback content while the wait lasts. The comparison with ConEstadoCarga has left a clear criterion for the rest of the course: until for values awaited once at a specific template position, and an explicit state property for operations that repeat or that need to communicate their state beyond that single position.
With this module's three central pieces now resolved —built-in directives, custom directives, and asynchronous rendering—, one last problem remains to be solved, different from everything so far: <task-filter>, mentioned since module 5 but never implemented, needs to communicate with <task-list> without either one knowing the other directly, and without <task-board> having to manually forward a property between the two on every change. The next lesson introduces @lit/context, the tool TaskFlow has needed since that problem was first raised, and with it, at last, <task-filter> comes to life.
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
