Lesson 05-04 deliberately left a piece of TaskFlow unfinished: <task-filter>, a sibling component of <task-list> that should let users filter the visible tasks, but which at the time could not be implemented without resorting to an awkward pattern —manually forwarding the filter text from <task-filter> up to <task-board>, and from <task-board> down to <task-list>, in two separate hops— or without getting ahead of content from this module. That same lesson mentioned, in passing, that a more elegant alternative existed: @lit/context. This lesson explains that alternative in detail and, with it, finally implements <task-filter>.
Contents
- The underlying problem: prop drilling and components with no direct relationship
createContext: defining a context keyContextProvider: publishing a value from the ancestorContextConsumer: reading the value from any descendant- Designing TaskFlow's filter context
<task-board>as provider<task-filter>: the missing component<task-list>as consumer: actually filtering- Context versus lifting state: the final criterion
- The underlying problem: prop drilling and components with no direct relationship
Lesson 05-04 resolved communication between <task-list> and <task-board> by lifting the shared state (the tareas array) to the common ancestor, with the already familiar cycle of event upward and property downward. That pattern works well while the hierarchy stays small, but it leaves two open questions that lesson mentioned without answering: what happens when the shared data has to cross several intermediate levels that don't even use it, just to travel from one end to the other (the problem known as prop drilling)? And what happens when two components with no close common-ancestor relationship —like <task-filter> and <task-list>, both direct siblings of <task-board>, each with its own responsibility— need to share a piece of data that, moreover, one of the two needs to update, not just read?
<task-filter> is exactly that second case: it needs to write a value (the search text or filter state chosen by the user), and <task-list> needs to read that same value to decide which tasks to show, without either of the two holding a direct reference to the other. Solving it with the pattern from lesson 05-04 would require <task-board> to keep the filter as its own state property, receive an event from <task-filter> every time it changes, and forward the new value as a property down to <task-list>: a perfectly valid cycle, but one that turns <task-board> into an obligatory intermediary for a piece of data it doesn't actually use for anything itself, only to pass it from one child to another.
createContext: defining a context key
createContext: defining a context key@lit/context is a package independent of Lit's core (installed separately, with npm install @lit/context), designed specifically for the problem from the previous section: it lets an ancestor component publish a value, and any descendant, at any depth, consume it directly, without any intermediate level needing to forward anything manually.
The first step is to define a context key, normally in a shared file that both the provider and the consumers can import:
// src/contexts/filtro-context.js
import { createContext } from '@lit/context';
export const filtroContext = createContext('filtro-tareas');createContext(...) does not create the shared value itself, but a unique identifier that acts as a key to recognize that particular context between the provider and its consumers; the argument ('filtro-tareas', in this case) is just a readable description intended for debugging, not a value that other code could use to "guess" or spoof the key from outside. A single project can define as many independent contexts as it needs, each with its own createContext(...) in its own module, exactly as TaskFlow defines here just one, dedicated exclusively to the task filter.
ContextProvider: publishing a value from the ancestor
ContextProvider: publishing a value from the ancestorA component becomes a context provider by instantiating the ContextProvider class, also imported from @lit/context, normally within its own constructor:
import { ContextProvider } from '@lit/context';
import { filtroContext } from '../contexts/filtro-context.js';
class TaskBoard extends LitElement {
constructor() {
super();
this._filtroProvider = new ContextProvider(this, {
context: filtroContext,
initialValue: { texto: '', estado: 'todas' },
});
}
}ContextProvider receives the component itself (this) as the host, plus a configuration object with the context key (context: filtroContext) and an initial value. Anyone already familiar with the reactive controller pattern, presented in lesson 06-03, will recognize a very similar design here: ContextProvider, just like ContadorTiempoRestanteController back then, registers itself on a host received as its first argument and hooks into its lifecycle without TaskBoard needing to inherit from any special class for that. This is not a stylistic coincidence: ContextProvider is, in fact, implemented internally as a ReactiveController, the same interface studied in module 6, reused here by Lit's own team to solve a different problem with the same piece of infrastructure.
To update the published value later on (for example, when the user changes the filter), it is enough to assign the provider's value property:
That assignment automatically notifies any consumer subscribed to that same context, without TaskBoard needing to know how many consumers there are or where they sit in the component tree.
ContextConsumer: reading the value from any descendant
ContextConsumer: reading the value from any descendantA component reads a context by symmetrically instantiating the ContextConsumer class:
import { ContextConsumer } from '@lit/context';
import { filtroContext } from '../contexts/filtro-context.js';
class TaskList extends LitElement {
constructor() {
super();
this._filtro = new ContextConsumer(this, {
context: filtroContext,
subscribe: true,
});
}
}this._filtro.value exposes, at all times, the current value published by the closest provider upward in the component tree that uses the same context key (filtroContext), without <task-list> needing any direct reference to <task-board> nor needing to know at exactly which level of the hierarchy that provider sits. The subscribe: true option is essential if the consumer is meant to keep receiving updates every time the provider changes its value over time (TaskFlow's case, where the filter changes repeatedly as the user types or clicks buttons); without it, ContextConsumer would only get the value available at the moment it was created, ignoring any later update from the provider, which is rarely what is wanted in a context designed for data that changes over time.
- Designing TaskFlow's filter context
With the theory already covered, it's time to decide exactly what shape the value shared by filtroContext should have. TaskFlow needs two pieces of information —a search text and a selected state ('todas', 'pendiente', or 'hecha')— plus a way for <task-filter> to be able to update that value without <task-board> needing to know about <task-filter> in any special way. The simplest solution, given that a context value can be any JavaScript value, including an object with methods, is to include the update function itself as part of the shared value:
Any consumer of this context receives, along with the data, a reference to the same actualizar function, which it can invoke directly to modify the filter, without needing a second communication channel (such as a custom event) for the "return" direction toward the provider.
<task-board> as provider
<task-board> as provider// src/components/task-board.js
import { LitElement, html, css } from 'lit';
import { ContextProvider } from '@lit/context';
import { filtroContext } from '../contexts/filtro-context.js';
import './task-list.js';
import './task-filter.js';
class TaskBoard extends LitElement {
static properties = {
tareas: { type: Array },
};
constructor() {
super();
this.tareas = [];
this._filtroProvider = new ContextProvider(this, {
context: filtroContext,
initialValue: {
texto: '',
estado: 'todas',
actualizar: (cambios) => this._actualizarFiltro(cambios),
},
});
}
_actualizarFiltro(cambios) {
this._filtroProvider.value = {
...this._filtroProvider.value,
...cambios,
};
}
render() {
return html`
<div class="tablero">
<h1>TaskFlow</h1>
<task-filter></task-filter>
<task-list .tareas="${this.tareas}" @tarea-cambiada="${this.gestionarTareaCambiada}"></task-list>
</div>
`;
}
}
customElements.define('task-board', TaskBoard);_actualizarFiltro(cambios) merges the received changes (for example, { texto: 'diseño' }, leaving estado untouched) with the current context value, and reassigns this._filtroProvider.value with the full combined object, once again including the actualizar function itself (necessary on every new value, since the entire object is being replaced, not just some of its fields). Note an important detail that sets this object apart from a regular reactive property: _actualizarFiltro is not declared in TaskBoard's static properties, because it isn't data that <task-board> itself needs to read in its render(); it is ContextProvider's exclusive responsibility to decide when to notify consumers, every time .value is assigned.
With this piece in place, <task-board> no longer forwards any filter-related property to <task-filter> or <task-list>: both are simply placed as direct children in the template, with no attribute or property related to the filter, exactly the goal lesson 05-04 left pending.
<task-filter>: the missing component
<task-filter>: the missing component// src/components/task-filter.js
import { LitElement, html, css } from 'lit';
import { classMap } from 'lit/directives/class-map.js';
import { ContextConsumer } from '@lit/context';
import { filtroContext } from '../contexts/filtro-context.js';
class TaskFilter extends LitElement {
constructor() {
super();
this._filtro = new ContextConsumer(this, { context: filtroContext, subscribe: true });
}
get valorActual() {
return this._filtro.value ?? { texto: '', estado: 'todas', actualizar: () => {} };
}
manejarTexto(evento) {
this.valorActual.actualizar({ texto: evento.target.value });
}
manejarEstado(estado) {
this.valorActual.actualizar({ estado });
}
render() {
const { texto, estado } = this.valorActual;
return html`
<div class="filtro">
<input
type="text"
placeholder="Buscar tarea…"
.value="${texto}"
@input="${this.manejarTexto}"
/>
<div class="filtro__botones">
${['todas', 'pendiente', 'hecha'].map(
(opcion) => html`
<button
class="${classMap({ activo: estado === opcion })}"
@click="${() => this.manejarEstado(opcion)}"
>
${{ todas: 'Todas', pendiente: 'Pendientes', hecha: 'Hechas' }[opcion]}
</button>
`
)}
</div>
</div>
`;
}
static styles = css`
.filtro__botones button.activo {
font-weight: bold;
border-bottom: 2px solid currentColor;
}
`;
}
customElements.define('task-filter', TaskFilter);<task-filter> declares no reactive property of its own for texto or estado: it reads them directly from this._filtro.value on every render(), and when the user types in the <input> or clicks a button, it calls this.valorActual.actualizar(...), the same function <task-board> published as part of the context value in section 6. Notice how this lesson ties back to the previous one: the state buttons use classMap, introduced in this module's first lesson, to toggle the activo class based on which of the three options matches estado, without needing three near-identical template blocks.
When manejarTexto or manejarEstado call actualizar(...), that call ultimately runs _actualizarFiltro on <task-board> (section 6), which reassigns this._filtroProvider.value; ContextProvider automatically takes care of notifying every subscribed consumer —including <task-filter> itself, and <task-list>, as shown in the next section— so they re-render with the new value.
<task-list> as consumer: actually filtering
<task-list> as consumer: actually filtering// src/components/task-list.js
import { LitElement, html } from 'lit';
import { repeat } from 'lit/directives/repeat.js';
import { ContextConsumer } from '@lit/context';
import { filtroContext } from '../contexts/filtro-context.js';
import './task-card.js';
class TaskList extends LitElement {
static properties = {
tareas: { type: Array },
};
constructor() {
super();
this.tareas = [];
this._filtro = new ContextConsumer(this, { context: filtroContext, subscribe: true });
}
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;
});
}
render() {
return html`
<ul>
${repeat(
this.tareasFiltradas,
(tarea) => tarea.id,
(tarea) => html`
<li>
<task-card
titulo="${tarea.titulo}"
estado="${tarea.estado}"
prioridad="${tarea.prioridad}"
></task-card>
</li>
`
)}
</ul>
`;
}
}
customElements.define('task-list', TaskList);tareasFiltradas combines the filter's two conditions —state match and text match, in lowercase so the search is case-insensitive— and render() uses that already-filtered result instead of the full tareas array. This is also the moment when the repeat directive, mentioned in passing since lesson 02-04, finally finds its natural use case in TaskFlow: every filter change alters which tasks are visible, inserting and removing items from the rendered list in real time; with Array.map, every filter change could make Lit compare by position and rebuild or reorder cards that are actually the same ones as before, risking the internal state of any <task-card> that happened to be expanded (remembered as internal state since module 3); with repeat and tarea.id as the key, each card keeps its identity and its state as long as it keeps matching the filter, and nodes are only created or destroyed for tasks that actually enter or leave the filtered result.
With this, <task-board> forwards absolutely nothing filter-related between <task-filter> and <task-list>: both read and write the same context directly, each with its own responsibility —one updates it, the other consumes it to decide what to show— without knowing about each other in any sense.
- Context versus lifting state: the final criterion
With @lit/context now applied, it's worth closing the comparison lesson 05-04 left open:
| Criterion | Lifting state (05-04) | @lit/context (this lesson) |
|---|---|---|
| Where the shared data lives | In a property of the common ancestor, explicitly forwarded downward | In a ContextProvider, directly accessible by any subscribed descendant |
| Do intermediate levels need to forward the data? | Yes, every level between the logical provider and the consumer | No: any descendant at any depth accesses it directly |
| Complexity for a two- or three-level hierarchy | Low: the event-up/property-down pattern is straightforward and easy to follow | Slightly higher: the context, the provider, and each consumer must be defined separately |
| Complexity for deeper hierarchies or siblings with no close common ancestor | Grows with every intermediate level that only acts as a middleman | Stays constant, regardless of depth |
| Example from this course | <task-board> → <task-list> → <task-card>, with tarea-cambiada events forwarded |
The filter between <task-filter> and <task-list>, siblings with no direct relationship |
The criterion, as lesson 05-04 already pointed out, hasn't changed: for small hierarchies and direct parent-to-child relationships, lifting state remains simpler to follow and debug, precisely because the data flow is explicit at every level of the component tree. @lit/context proves its worth when, as in the case of <task-filter> and <task-list>, two components with no direct relationship need to share a piece of data without some unrelated third party having to act as a forced intermediary.
Common Mistakes and Tips
- Forgetting
subscribe: trueon aContextConsumer: as explained in section 4, without this option the consumer only receives the value available at the moment it was created, and never updates again even if the provider changes its value later on; the symptom is a component that seems "frozen" with the first filter value, unresponsive to the user's changes. - Reassigning only part of the context object, losing the
actualizarfunction: as pointed out in section 6, every assignment tothis._filtroProvider.valuereplaces the entire object; if_actualizarFiltroforgot to includeactualizarin the new object, any consumer later callingthis.valorActual.actualizar(...)would fail, because that function would no longer exist in the new value. - Using
@lit/contextfor data that only a single direct child needs: as section 9 recalls, for a simple parent-to-child relationship (like<task-board>passingtareasto<task-list>), a regular property remains simpler and more explicit; introducing a context there only adds indirection without solving any real prop drilling problem. - Expecting a consumer with no active provider to throw a clear error: if no ancestor of a component provides the context it is trying to consume,
this._filtro.valuesimply ends upundefined, with no explicit error; that's whyvalorActualandtareasFiltradas, in sections 7 and 8, use?? { ... }to offer a reasonable default in that case, instead of assuming the context will always be available.
Exercises
- Add a third field,
prioridadMinima(defaulting to0), to the filter context value, and modifytareasFiltradasin<task-list>to also exclude tasks withtarea.prioridad < prioridadMinima. No new visual control needs to be added to<task-filter>for this exercise; the filtering logic alone is enough. - Explain, based on section 3, why
ContextProviderneeds to receive the host (this) as the first argument to its constructor, drawing on the parallel pointed out withContadorTiempoRestanteControllerfrom lesson 06-03. - A teammate proposes that, instead of including the
actualizarfunction inside the context value itself (section 5),<task-filter>should dispatch aCustomEventwithbubbles: true, composed: true(as in lesson 05-02) that<task-board>listens for in order to update the filter. Explain what advantage this lesson's approach (the function inside the context) keeps over that alternative, in terms of what<task-filter>needs to know about its position in the component tree.
Solutions
get tareasFiltradas() {
const { texto, estado, prioridadMinima = 0 } = 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);
const coincidePrioridad = tarea.prioridad >= prioridadMinima;
return coincideEstado && coincideTexto && coincidePrioridad;
});
}ContextProvider, just likeContadorTiempoRestanteController, needs the host for two reasons identical to those explained in lesson 06-03: first, to register itself on its lifecycle as aReactiveController(so it knows when the component connects to or disconnects from the DOM, relevant for starting or stopping listening for context requests from descendants); second, to know at exactly which node of the component tree it must anchor itself as a provider, since@lit/contextlocates the closest provider by walking up the DOM from each consumer, and that walk needs a real starting point in the tree, which is precisely the host received as the first argument.- With this lesson's approach,
<task-filter>doesn't need to know anything about its position in the component tree, nor about who, at some higher level, will react to its changes: it simply calls a function it receives as part of the context value, with no assumption about which component implements it or how far away it sits. With aCustomEvent,<task-filter>would still not need to know<task-board>directly (thanks tobubbles/composed, as in lesson 05-02), but<task-board>would have to explicitly declare an event handler for each type of filter change, whereas with this lesson's context a single genericactualizarfunction is enough, able to accept any combination of changes ({ texto: ... },{ estado: ... }, or both at once) without needing a different event type for each filter field.
Conclusion
This lesson has finally closed the pending mention from lesson 05-04: @lit/context, with createContext, ContextProvider, and ContextConsumer, has resolved communication between <task-filter> and <task-list> with no direct relationship between them and without <task-board> having to manually forward any filter property. With this piece, TaskFlow completes its component hierarchy exactly as had been building up since module 5: <task-board> as orchestrator and sole context provider, <task-list> filtering and rendering the visible tasks with repeat, <task-card> (with <user-avatar> inside) showing each task with its status badges and its urgency computed by ContadorTiempoRestanteController, and <task-filter>, the component that had gone three modules mentioned but unimplemented, updating the shared filter directly through the context.
With TaskFlow now functionally complete, it's time to see how it integrates with the rest of the world: plain HTML, other frameworks, SSR, and bundling.
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
