La lección 05-04 dejó una pieza de TaskFlow pendiente a propósito: <task-filter>, un componente hermano de <task-list> que debería permitir filtrar las tareas visibles, pero que en aquel momento no se podía implementar sin recurrir a un patrón incómodo —reenviar manualmente el texto de filtro desde <task-filter> hacia <task-board>, y de <task-board> hacia <task-list>, en dos saltos separados— o sin adelantar contenido de este módulo. Aquella misma lección mencionó, de pasada, que existía una alternativa más elegante: @lit/context. Esta lección explica esa alternativa en detalle y, con ella, implementa por fin <task-filter>.
Contenido
- El problema de fondo: prop drilling y componentes sin relación directa
createContext: definiendo una clave de contextoContextProvider: publicando un valor desde el ancestroContextConsumer: leyendo el valor desde cualquier descendiente- Diseñando el contexto de filtro de TaskFlow
<task-board>como proveedor<task-filter>: el componente que faltaba<task-list>como consumidor: filtrando de verdad- Contexto frente a levantar el estado: el criterio final
- El problema de fondo: prop drilling y componentes sin relación directa
La lección 05-04 resolvió la comunicación entre <task-list> y <task-board> levantando el estado compartido (el array tareas) al ancestro común, con el ciclo ya conocido de evento hacia arriba y propiedad hacia abajo. Ese patrón funciona bien mientras la jerarquía sea pequeña, pero deja dos preguntas abiertas que aquella lección mencionó sin responder: ¿qué pasa cuando el dato compartido tiene que atravesar varios niveles intermedios que ni siquiera lo usan, solo para llegar de un extremo a otro (el problema conocido como prop drilling, "perforación de propiedades")? ¿Y qué pasa cuando dos componentes sin ninguna relación de ancestro común cercano —como <task-filter> y <task-list>, ambos hermanos directos de <task-board>, cada uno con su propia responsabilidad— necesitan compartir un dato que, además, uno de los dos necesita actualizar, no solo leer?
<task-filter> es exactamente ese segundo caso: necesita escribir un valor (el texto o el estado de filtro elegido por el usuario), y <task-list> necesita leer ese mismo valor para decidir qué tareas mostrar, sin que ninguno de los dos tenga una referencia directa al otro. Resolverlo con el patrón de la lección 05-04 exigiría que <task-board> mantuviera el filtro como propiedad de estado propia, recibiera un evento de <task-filter> cada vez que cambia, y reenviara el nuevo valor como propiedad hacia <task-list>: un ciclo perfectamente válido, pero que convierte a <task-board> en un intermediario obligado de un dato que, en realidad, no usa para nada por sí mismo, solo para pasarlo de un hijo a otro.
createContext: definiendo una clave de contexto
createContext: definiendo una clave de contexto@lit/context es un paquete independiente del núcleo de Lit (se instala por separado, con npm install @lit/context), pensado específicamente para el problema del apartado anterior: permite que un componente ancestro publique un valor, y que cualquier descendiente, a cualquier profundidad, lo consuma directamente, sin que ningún nivel intermedio necesite reenviar nada manualmente.
El primer paso es definir una clave de contexto, normalmente en un fichero compartido que tanto el proveedor como los consumidores puedan importar:
// src/contexts/filtro-context.js
import { createContext } from '@lit/context';
export const filtroContext = createContext('filtro-tareas');createContext(...) no crea el valor compartido en sí mismo, sino un identificador único que actúa de clave para reconocer ese contexto concreto entre el proveedor y sus consumidores; el argumento ('filtro-tareas', en este caso) es solo una descripción legible pensada para depuración, no un valor que otro código pueda usar para "adivinar" o falsificar la clave desde fuera. Un mismo proyecto puede definir tantos contextos independientes como necesite, cada uno con su propio createContext(...) en su propio módulo, exactamente como TaskFlow define aquí uno solo, dedicado en exclusiva al filtro de tareas.
ContextProvider: publicando un valor desde el ancestro
ContextProvider: publicando un valor desde el ancestroUn componente se convierte en proveedor de un contexto instanciando la clase ContextProvider, importada también de @lit/context, normalmente en su propio 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 recibe el propio componente (this) como host, y un objeto de configuración con la clave de contexto (context: filtroContext) y un valor inicial. Quien conozca ya el patrón de los controladores reactivos, presentado en la lección 06-03, reconocerá aquí un diseño muy similar: ContextProvider, igual que ContadorTiempoRestanteController en su momento, se registra sobre un host recibido como primer argumento y se engancha a su ciclo de vida sin que TaskBoard necesite heredar de ninguna clase especial para ello. No es una coincidencia de estilo: ContextProvider está, de hecho, implementado internamente como un ReactiveController, la misma interfaz estudiada en el módulo 6, reutilizada aquí por el propio equipo de Lit para resolver un problema distinto con la misma pieza de infraestructura.
Para actualizar el valor publicado más adelante (por ejemplo, cuando el usuario cambia el filtro), basta con asignar la propiedad value del proveedor:
Esa asignación notifica automáticamente a cualquier consumidor suscrito a ese mismo contexto, sin que TaskBoard necesite conocer cuántos consumidores hay ni dónde están colocados en el árbol de componentes.
ContextConsumer: leyendo el valor desde cualquier descendiente
ContextConsumer: leyendo el valor desde cualquier descendienteUn componente lee un contexto instanciando, de forma simétrica, la clase ContextConsumer:
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 expone, en todo momento, el valor actual publicado por el proveedor más cercano hacia arriba en el árbol de componentes que use la misma clave de contexto (filtroContext), sin que <task-list> necesite ninguna referencia directa a <task-board> ni conocer en qué nivel exacto de la jerarquía se encuentra ese proveedor. La opción subscribe: true es imprescindible si se quiere que el consumidor siga recibiendo actualizaciones cada vez que el proveedor cambie su valor a lo largo del tiempo (el caso de TaskFlow, donde el filtro cambia repetidamente mientras el usuario escribe o pulsa botones); sin ella, ContextConsumer solo obtendría el valor disponible en el momento de crearse, ignorando cualquier actualización posterior del proveedor, algo que rara vez es lo que se busca en un contexto pensado para datos que cambian con el tiempo.
- Diseñando el contexto de filtro de TaskFlow
Con la teoría ya cubierta, toca decidir qué forma debe tener exactamente el valor compartido por filtroContext. TaskFlow necesita dos piezas de información —un texto de búsqueda y un estado seleccionado ('todas', 'pendiente' o 'hecha')— y, además, una forma de que <task-filter> pueda actualizar ese valor sin necesidad de que <task-board> conozca a <task-filter> de ninguna forma especial. La solución más simple, dado que un valor de contexto puede ser cualquier valor de JavaScript, incluido un objeto con métodos, es incluir la propia función de actualización como parte del valor compartido:
Cualquier consumidor de este contexto recibe, junto con los datos, una referencia a la misma función actualizar, que puede invocar directamente para modificar el filtro, sin necesidad de un segundo canal de comunicación (como un evento personalizado) para la dirección "de vuelta" hacia el proveedor.
<task-board> como proveedor
<task-board> como proveedor// 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) fusiona los cambios recibidos (por ejemplo, { texto: 'diseño' }, sin tocar estado) con el valor actual del contexto, y reasigna this._filtroProvider.value con el objeto combinado completo, incluyendo de nuevo la propia función actualizar (necesaria en cada nuevo valor, ya que se está sustituyendo el objeto entero, no solo alguno de sus campos). Nótese un detalle importante que distingue este objeto de una propiedad reactiva corriente: _actualizarFiltro no está declarado en static properties de TaskBoard, porque no es un dato que el propio <task-board> necesite leer en su render(); es responsabilidad exclusiva de ContextProvider decidir cuándo notificar a los consumidores, cada vez que se asigna .value.
Con esta pieza, <task-board> ya no reenvía ninguna propiedad de filtro hacia <task-filter> ni hacia <task-list>: ambos se colocan simplemente como hijos directos en la plantilla, sin ningún atributo ni propiedad relacionada con el filtro, exactamente el objetivo que la lección 05-04 dejó pendiente.
<task-filter>: el componente que faltaba
<task-filter>: el componente que faltaba// 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> no declara ninguna propiedad reactiva propia para texto ni estado: los lee directamente de this._filtro.value en cada render(), y cuando el usuario escribe en el <input> o pulsa un botón, llama a this.valorActual.actualizar(...), la misma función que <task-board> publicó como parte del valor de contexto en el apartado 6. Nótese cómo esta lección enlaza con la anterior: los botones de estado usan classMap, presentada en la primera lección de este módulo, para alternar la clase activo según cuál de las tres opciones coincida con estado, sin necesidad de tres bloques de plantilla casi idénticos.
Cuando manejarTexto o manejarEstado llaman a actualizar(...), esa llamada ejecuta finalmente _actualizarFiltro en <task-board> (apartado 6), que reasigna this._filtroProvider.value; ContextProvider se encarga, automáticamente, de notificar a todos los consumidores suscritos —incluido el propio <task-filter>, y <task-list>, como se ve en el siguiente apartado— para que vuelvan a renderizarse con el nuevo valor.
<task-list> como consumidor: filtrando de verdad
<task-list> como consumidor: filtrando de verdad// 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 combina las dos condiciones del filtro —coincidencia de estado y coincidencia de texto, en minúsculas para que la búsqueda no distinga mayúsculas— y render() usa ese resultado, ya filtrado, en lugar del array tareas completo. Este es, además, el momento en que la directiva repeat, mencionada de pasada desde la lección 02-04, encuentra por fin su caso de uso natural en TaskFlow: cada cambio de filtro modifica qué tareas están visibles, insertando y eliminando elementos de la lista renderizada en tiempo real; con Array.map, cada cambio de filtro podría hacer que Lit compare por posición y reconstruya o reordene tarjetas que en realidad son las mismas de antes, arriesgando el estado interno de cualquier <task-card> que estuviera expandida (recordada como estado interno desde el módulo 3); con repeat y tarea.id como clave, cada tarjeta conserva su identidad y su estado mientras siga cumpliendo el filtro, y solo se crean o destruyen nodos para las tareas que realmente entran o salen del resultado filtrado.
Con esto, <task-board> no reenvía absolutamente nada relacionado con el filtro entre <task-filter> y <task-list>: ambos leen y escriben el mismo contexto de forma directa, cada uno con su propia responsabilidad —uno lo actualiza, el otro lo consume para decidir qué mostrar— sin conocerse entre sí en ningún sentido.
- Contexto frente a levantar el estado: el criterio final
Con @lit/context ya aplicado, conviene cerrar la comparación que la lección 05-04 dejó abierta:
| Criterio | Levantar el estado (05-04) | @lit/context (esta lección) |
|---|---|---|
| Dónde vive el dato compartido | En una propiedad del ancestro común, reenviada explícitamente hacia abajo | En un ContextProvider, accesible directamente por cualquier descendiente suscrito |
| ¿Los niveles intermedios necesitan reenviar el dato? | Sí, cada nivel que esté entre el proveedor lógico y el consumidor | No: cualquier descendiente a cualquier profundidad accede directamente |
| Complejidad para una jerarquía de dos o tres niveles | Baja: el patrón de evento arriba / propiedad abajo es directo y fácil de seguir | Ligeramente mayor: hay que definir el contexto, el proveedor y cada consumidor por separado |
| Complejidad para jerarquías más profundas o con hermanos que no comparten ancestro inmediato | Crece con cada nivel intermedio que solo hace de intermediario | Se mantiene constante, independientemente de la profundidad |
| Ejemplo de este curso | <task-board> → <task-list> → <task-card>, con eventos tarea-cambiada reenviados |
El filtro entre <task-filter> y <task-list>, hermanos sin relación directa |
El criterio, tal como ya apuntaba la lección 05-04, no ha cambiado: para jerarquías pequeñas y relaciones directas de padre a hijo, levantar el estado sigue siendo más simple de seguir y depurar, precisamente porque el flujo de datos es explícito en cada nivel del árbol de componentes. @lit/context demuestra su valor cuando, como en el caso de <task-filter> y <task-list>, dos componentes sin relación directa necesitan compartir un dato sin que un tercero ajeno tenga que hacer de intermediario obligado.
Errores Comunes y Consejos
- Olvidar
subscribe: trueen unContextConsumer: como se explicó en el apartado 4, sin esta opción el consumidor solo recibe el valor disponible en el momento de crearse, y nunca vuelve a actualizarse aunque el proveedor cambie su valor más adelante; el síntoma es un componente que parece "congelado" con el primer valor de filtro, sin reaccionar a los cambios del usuario. - Reasignar solo parte del objeto de contexto, perdiendo la función
actualizar: como se ha señalado en el apartado 6, cada asignación athis._filtroProvider.valuesustituye el objeto completo; si_actualizarFiltroolvidara incluiractualizaren el nuevo objeto, cualquier consumidor que llamara después athis.valorActual.actualizar(...)fallaría, porque esa función ya no existiría en el nuevo valor. - Usar
@lit/contextpara datos que solo necesita un único hijo directo: como recuerda el apartado 9, para una relación simple de padre a hijo (como<task-board>pasandotareasa<task-list>), una propiedad normal sigue siendo más simple y explícita; introducir un contexto ahí solo añade indirección sin resolver ningún problema real de prop drilling. - Esperar que un consumidor sin proveedor activo lance un error claro: si ningún ancestro de un componente proporciona el contexto que intenta consumir,
this._filtro.valuequeda simplementeundefined, sin ningún error explícito; por esovalorActualytareasFiltradas, en los apartados 7 y 8, usan?? { ... }para ofrecer un valor por defecto razonable en ese caso, en lugar de asumir que el contexto siempre estará disponible.
Ejercicios
- Añade al valor de contexto de filtro un tercer campo,
prioridadMinima(con0como valor por defecto), y modificatareasFiltradasen<task-list>para excluir también las tareas contarea.prioridad < prioridadMinima. No hace falta añadir ningún control visual nuevo en<task-filter>para este ejercicio, basta con la lógica de filtrado. - Explica, basándote en el apartado 3, por qué
ContextProvidernecesita recibir el host (this) como primer argumento de su constructor, apoyándote en el paralelismo señalado conContadorTiempoRestanteControllerde la lección 06-03. - Un compañero de equipo propone que, en lugar de incluir la función
actualizardentro del propio valor de contexto (apartado 5),<task-filter>despache unCustomEventconbubbles: true, composed: true(como en la lección 05-02) que<task-board>escuche para actualizar el filtro. Explica qué ventaja conserva el enfoque de esta lección (la función dentro del contexto) frente a esa alternativa, en términos de qué necesita saber<task-filter>sobre su posición en el árbol de componentes.
Soluciones
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, igual queContadorTiempoRestanteController, necesita el host para dos motivos idénticos a los explicados en la lección 06-03: primero, para registrarse sobre su ciclo de vida comoReactiveController(de modo que sepa cuándo el componente se conecta o desconecta del DOM, relevante para empezar o dejar de escuchar peticiones de contexto desde los descendientes); segundo, para saber en qué nodo exacto del árbol de componentes debe anclarse como proveedor, ya que@lit/contextlocaliza al proveedor más cercano recorriendo el DOM hacia arriba desde cada consumidor, y ese recorrido necesita un punto de partida real en el árbol, que es precisamente el host recibido como primer argumento.- Con el enfoque de esta lección,
<task-filter>no necesita saber nada sobre su posición en el árbol de componentes ni sobre quién, en algún nivel superior, va a reaccionar a sus cambios: simplemente llama a una función que recibe como parte del valor de contexto, sin ninguna suposición sobre qué componente la implementa ni a qué distancia se encuentra. Con unCustomEvent,<task-filter>seguiría sin necesitar conocer a<task-board>directamente (gracias abubbles/composed, como en la lección 05-02), pero<task-board>tendría que declarar explícitamente un manejador de eventos para cada tipo de cambio de filtro, mientras que con el contexto de esta lección basta con una única funciónactualizargenérica, capaz de aceptar cualquier combinación de cambios ({ texto: ... },{ estado: ... }, o ambos a la vez) sin necesidad de un tipo de evento distinto para cada campo del filtro.
Conclusión
Esta lección ha cerrado, por fin, la mención pendiente desde la lección 05-04: @lit/context, con createContext, ContextProvider y ContextConsumer, ha resuelto la comunicación entre <task-filter> y <task-list> sin relación directa entre ambos y sin que <task-board> tuviera que reenviar manualmente ninguna propiedad de filtro. Con esta pieza, TaskFlow completa su jerarquía de componentes tal como se había ido anunciando desde el módulo 5: <task-board> como orquestador y único proveedor de contexto, <task-list> filtrando y renderizando con repeat las tareas visibles, <task-card> (con <user-avatar> dentro) mostrando cada tarea con sus insignias de estado y su urgencia calculada por ContadorTiempoRestanteController, y <task-filter>, el componente que llevaba tres módulos mencionado sin implementar, actualizando el filtro compartido directamente a través del contexto.
Con TaskFlow funcionalmente completo, toca ver cómo se integra con el resto del mundo: HTML plano, otros frameworks, SSR y empaquetado.
Curso de Lit
Módulo 1: Introducción a Lit y Web Components
- ¿Qué son los Web Components y por qué Lit?
- Configuración del Entorno de Desarrollo
- Tu Primer Componente Lit
- Anatomía de un Componente Lit
Módulo 2: Plantillas Reactivas y Renderizado
- El Motor de Plantillas de Lit
- Expresiones e Interpolación en Plantillas
- Renderizado Condicional
- Renderizado de Listas
- El Ciclo de Renderizado
Módulo 3: Propiedades y Estado Reactivo
- Propiedades Reactivas
- Estado Interno con @state
- Tipos de Propiedades y Conversores Personalizados
- Atributos vs Propiedades y Reflexión
Módulo 4: Estilos en Componentes Lit
- CSS Encapsulado con Shadow DOM
- Estilos Compartidos entre Componentes
- Variables CSS Personalizadas y Theming
- Slots y Estilizado de Contenido Distribuido
Módulo 5: Eventos y Comunicación entre Componentes
- Manejo de Eventos DOM en Plantillas
- Eventos Personalizados: Comunicación Hijo a Padre
- Comunicación de Padre a Hijo con Propiedades
- Patrones de Comunicación entre Componentes Hermanos
Módulo 6: Ciclo de Vida y Comportamiento Avanzado
- Callbacks del Ciclo de Vida
- Hooks Reactivos: willUpdate, updated y firstUpdated
- Controladores Reactivos
- Mixins y Composición de Comportamiento
Módulo 7: Directivas y Funcionalidades Avanzadas de Plantillas
- Directivas Incorporadas: classMap, styleMap e ifDefined
- Directivas Personalizadas
- Renderizado Asíncrono con until
- Contexto Compartido con @lit/context
Módulo 8: Integración, Interoperabilidad y Despliegue
- Usar Componentes Lit en HTML Plano
- Integrar Lit con React, Vue y Angular
- Renderizado en el Servidor con @lit-labs/ssr
- Empaquetado, Publicación y TypeScript
Módulo 9: Pruebas y Buenas Prácticas
- Pruebas Unitarias con Web Test Runner
- Accesibilidad en Web Components
- Rendimiento y Optimización
- Patrones y Anti-patrones Comunes
