Las dos lecciones anteriores han dejado a <task-card> con una pieza de lógica autocontenida pero atrapada dentro de su propia clase: un temporizador que arranca en connectedCallback, se detiene en disconnectedCallback, y un cálculo (_calcularSiCercaDeVencer) que ambos comparten. Funciona perfectamente para <task-card>, pero si mañana <task-board> necesitara mostrar, por ejemplo, un resumen con el número de tareas cercanas a su fecha límite, esa misma lógica tendría que copiarse y pegarse en una segunda clase, con el riesgo de que las dos copias acaben divergiendo con el tiempo. Esta lección presenta la herramienta que Lit ofrece específicamente para este problema: los controladores reactivos, objetos reutilizables capaces de engancharse al ciclo de vida de cualquier componente sin necesidad de heredar de él.
Contenido
- El problema: lógica con ciclo de vida propio, atrapada en una clase
- Qué es un
ReactiveController - La interfaz de un controlador:
hostConnected,hostDisconnected,hostUpdate,hostUpdated addController: registrando un controlador en su host- Extrayendo el temporizador de
<task-card>a un controlador reutilizable - Usando el controlador desde
<task-card> - Por qué un controlador y no simplemente una función auxiliar
- Cierre: controladores frente a mixins
- El problema: lógica con ciclo de vida propio, atrapada en una clase
La lógica añadida en la lección 06-01 tiene una característica particular que la distingue de un simple método auxiliar: necesita engancharse al ciclo de vida del componente que la usa. No basta con extraer _calcularSiCercaDeVencer a una función independiente (eso ya sería trivial, sin necesidad de ningún concepto nuevo); el problema de fondo es que el temporizador en sí —cuándo arranca, cuándo se detiene— depende de cuándo <task-card> se conecta y se desconecta del DOM, y esa dependencia del ciclo de vida es, precisamente, lo que hace que copiar y pegar esta lógica en un segundo componente sea tan tentador como propenso a errores: cualquier corrección futura (por ejemplo, ajustar el margen de "cerca de vencer" de 24 horas a 48) tendría que aplicarse en todas las copias por separado.
- Qué es un
ReactiveController
ReactiveControllerUn ReactiveController es, en su forma más simple, un objeto corriente de JavaScript (no necesita heredar de ninguna clase de Lit) que implementa una o varias de un pequeño conjunto de funciones con nombres reservados, y que se registra explícitamente sobre un componente Lit (llamado, en esta relación, su host) para recibir avisos en los mismos momentos clave del ciclo de vida que ya se han estudiado en las lecciones anteriores de este módulo.
La idea central, y la que resuelve el problema del apartado 1, es que un mismo controlador —una misma clase de JavaScript— puede registrarse sobre tantos hosts distintos como haga falta, cada uno con su propia instancia del controlador, sin que ninguno necesite heredar de una clase base común ni duplicar código: el controlador encapsula tanto el estado (por ejemplo, el identificador del intervalo activo) como el comportamiento (arrancarlo, detenerlo, calcular el resultado), y el componente que lo usa se limita a mantener una referencia al controlador y a leer, desde su render(), el dato que el controlador expone.
- La interfaz de un controlador:
hostConnected, hostDisconnected, hostUpdate, hostUpdated
hostConnected, hostDisconnected, hostUpdate, hostUpdatedUn controlador puede implementar cualquiera de estos métodos, todos opcionales, y Lit los llama automáticamente en el momento correspondiente del ciclo de vida de su host:
| Método del controlador | Se llama cuando el host ejecuta... | Equivalente conceptual visto en este módulo |
|---|---|---|
hostConnected() |
connectedCallback() |
Lección 06-01: arrancar un efecto activo |
hostDisconnected() |
disconnectedCallback() |
Lección 06-01: limpiar ese efecto |
hostUpdate() |
Antes de render(), en cada actualización |
Lección 06-02: equivalente a willUpdate |
hostUpdated() |
Después de cada render(), con el DOM ya actualizado |
Lección 06-02: equivalente a updated |
Esta correspondencia no es casual: los controladores reactivos están diseñados deliberadamente como una vía alternativa para engancharse a los mismos puntos exactos del ciclo de vida que ya se han estudiado como métodos de la propia clase del componente, con la diferencia de que, en lugar de vivir mezclados dentro de la clase del componente, viven en un objeto aparte, reutilizable de forma independiente.
addController: registrando un controlador en su host
addController: registrando un controlador en su hostPara que un controlador empiece a recibir estos avisos, su host —cualquier LitElement, sin necesidad de ninguna configuración adicional— debe registrarlo explícitamente con this.addController(controlador), normalmente dentro del constructor del componente:
class TaskCard extends LitElement {
constructor() {
super();
this._miControlador = new AlgunControlador(this);
}
}Es habitual, como en este ejemplo, que el propio constructor del controlador reciba el host como argumento y llame él mismo a host.addController(this) internamente, de forma que instanciar el controlador y registrarlo sea un único paso, tal como se verá en el apartado siguiente. A partir de ese registro, Lit se encarga de invocar los métodos de la tabla anterior en el momento adecuado, exactamente igual que si fueran métodos de la propia clase del componente.
- Extrayendo el temporizador de
<task-card> a un controlador reutilizable
<task-card> a un controlador reutilizableCon la interfaz ya clara, la lógica de la lección 06-01 se traslada, casi sin cambios conceptuales, a una clase independiente:
// src/controllers/contador-tiempo-restante-controller.js
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 fechaLimite = this.host.fechaLimite;
const nuevoValor = this._calcularSiCercaDeVencer(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;
}
}Varios detalles distinguen este código del original de la lección 06-01, y todos responden a la misma necesidad: hacerlo reutilizable por cualquier host, no solo por <task-card>.
- El constructor guarda una referencia al
hostrecibido como parámetro, y llama él mismo ahost.addController(this): quien use este controlador no necesita acordarse de llamar aaddControllerpor separado, basta con instanciarlo pasándolethis(el propio componente) como argumento. - El margen de "cerca de vencer" (
margenMs) y la frecuencia de comprobación (intervaloMs) se han convertido en parámetros configurables, con valores por defecto razonables, en lugar de estar fijados directamente en el código como en la lección 06-01: esto permite que un futuro segundo host use, por ejemplo, un margen de 48 horas sin tener que copiar y modificar el controlador. - El controlador lee
this.host.fechaLimitedirectamente, asumiendo que cualquier host que lo use expondrá una propiedad con ese nombre. Esta es la única suposición que el controlador hace sobre su host, y conviene documentarla con claridad: un controlador reutilizable siempre necesita algún contrato mínimo sobre lo que su host debe ofrecerle. - Fundamental: en lugar de asignar directamente una propiedad reactiva del host (
this.host.cercaDeVencer = ..., como hacía<task-card>en la lección 06-01), el controlador guarda su propio resultado en un campo propio (this.cercaDeVencer, en el propio controlador, no en el host) y llama explícitamente athis.host.requestUpdate()para pedirle a Lit que vuelva a ejecutarrender(). El controlador no necesita declarar ninguna propiedad reactiva de Lit para su propio estado interno; le basta conrequestUpdate(), exactamente el mismo mecanismo de bajo nivel estudiado en el módulo 2, para provocar una actualización cada vez que su resultado cambia.
- Usando el controlador desde
<task-card>
<task-card>Con el controlador ya extraído, <task-card> se simplifica notablemente: desaparecen connectedCallback, disconnectedCallback y _calcularSiCercaDeVencer de la lección 06-01, sustituidos por una única instancia del controlador.
// src/components/task-card.js
import { ContadorTiempoRestanteController } from '../controllers/contador-tiempo-restante-controller.js';
class TaskCard extends LitElement {
static properties = {
titulo: { type: String },
estado: { type: String },
prioridad: { type: Number },
urgente: { type: Boolean },
expandida: { state: true },
fechaLimite: { converter: conversorDeFecha, attribute: 'fecha-limite' },
};
constructor() {
super();
this.titulo = '';
this.estado = 'pendiente';
this.prioridad = 1;
this.urgente = false;
this.expandida = false;
this.fechaLimite = null;
this._contadorTiempo = new ContadorTiempoRestanteController(this);
}
render() {
return html`
<article @click="${this.alternarExpandida}">
<h3>${this.titulo}</h3>
${this.renderInsigniaEstado()}
${this.renderSelectorEstado()}
<p>Prioridad: ${this.prioridad}</p>
${this.urgente && html`<p class="aviso">⚠ Urgente</p>`}
${this._contadorTiempo.cercaDeVencer ? html`<p class="aviso">⏰ Está a punto de vencer</p>` : ''}
${this.expandida
? html`<div class="detalle"><p>Estado interno: la tarjeta está expandida.</p></div>`
: ''}
</article>
`;
}
}Nótese que cercaDeVencer ya no existe como propiedad de estado de <task-card> (ha desaparecido de static properties): ahora vive exclusivamente dentro del controlador, y render() lo lee directamente como this._contadorTiempo.cercaDeVencer. <task-card> ni siquiera necesita saber que, internamente, ese valor se actualiza mediante un setInterval; solo necesita saber que el controlador expone una propiedad cercaDeVencer legible en cualquier momento, y que Lit volverá a ejecutar render() automáticamente cada vez que ese valor cambie, gracias al requestUpdate() que el propio controlador invoca.
Si más adelante <task-board> necesitara mostrar cuántas tareas están cerca de vencer, podría instanciar su propio ContadorTiempoRestanteController para cada tarea de this.tareas (o, más simple, reutilizar el mismo cálculo agregándolo desde varias instancias ya creadas en cada <task-card>), sin duplicar ni una sola línea de la lógica del temporizador: el controlador ya está escrito, probado y listo para usarse en cualquier host que exponga una propiedad fechaLimite.
- Por qué un controlador y no simplemente una función auxiliar
Cabe preguntarse por qué hace falta un objeto con esta interfaz concreta, en lugar de limitarse a extraer _calcularSiCercaDeVencer a una función suelta e importarla donde haga falta. La respuesta está en lo que una función suelta no puede resolver por sí sola: el ciclo de vida y el estado que persiste entre llamadas. Una función auxiliar sin estado propio serviría perfectamente para el cálculo puntual (_calcularSiCercaDeVencer(fechaLimite) ya lo era, de hecho, desde la lección 06-01), pero no puede, por sí misma, arrancar un temporizador cuando el host se conecta, detenerlo cuando se desconecta, ni recordar el identificador de ese temporizador entre una llamada y la siguiente. Eso exige un objeto con estado propio (el _idIntervalo, el cercaDeVencer actual) y con enganches al ciclo de vida del host —exactamente lo que la interfaz de ReactiveController proporciona de forma estandarizada, en lugar de que cada componente reinvente su propia manera de coordinar temporizadores con connectedCallback y disconnectedCallback.
- Cierre: controladores frente a mixins
Los controladores reactivos son, en la documentación y en la práctica habitual del ecosistema de Lit, la alternativa recomendada frente a otra técnica más antigua y más general de JavaScript para componer comportamiento reutilizable entre clases: los mixins. Ambas herramientas persiguen el mismo objetivo general (reutilizar lógica entre varios componentes sin copiar y pegar código), pero lo hacen de formas suficientemente distintas como para que merezca la pena dedicarles una comparación completa antes de decidir cuál usar en cada caso. Esa comparación, junto con el patrón de los mixins en sí, es el contenido de la siguiente lección.
Errores Comunes y Consejos
- Asignar directamente una propiedad reactiva del host desde el controlador: como se ha señalado en el apartado 5, un controlador no necesita declarar propiedades reactivas propias en el host; le basta con mantener su propio estado interno y llamar a
this.host.requestUpdate()cuando ese estado cambie. Intentar hacerthis.host.cercaDeVencer = ...obligaría además a que el host declarase esa propiedad en su propiostatic properties, acoplando la implementación interna del controlador a la declaración de propiedades de cada host concreto. - Olvidar
host.addController(this)dentro del propio constructor del controlador: sin este registro, Lit nunca llama ahostConnectedni ahostDisconnected, y el controlador se comporta como un objeto corriente sin ninguna conexión real al ciclo de vida de su host, a pesar de que su código pueda parecer correcto a simple vista. - Hacer que el controlador dependa de propiedades muy específicas de un único componente: cuanto más genérico sea el contrato que el controlador exige de su host (en este ejemplo, una única propiedad
fechaLimite, con parámetros configurables para el resto), más fácil será reutilizarlo en componentes futuros que ni siquiera existen todavía; un controlador que asumiera, por ejemplo, la existencia de un método muy concreto de<task-card>perdería buena parte de su valor como pieza reutilizable. - No limpiar recursos en
hostDisconnected: exactamente el mismo riesgo señalado en la lección 06-01 sobredisconnectedCallbackse aplica aquí; un controlador que arranca un temporizador o una suscripción enhostConnectedy no lo libera enhostDisconnectedprovoca la misma fuga de memoria, ahora potencialmente multiplicada por cada host que lo use.
Ejercicios
- Añade al
ContadorTiempoRestanteControllerun segundo parámetro de configuración,onCambio, una función de callback opcional que el controlador invoque (además de llamar arequestUpdate()) cada vez quecercaDeVencercambie de valor, pasándole el nuevo valor como argumento. Modifica la instanciación en<task-card>para aprovechar este callback y mostrar unconsole.logcuando la tarjeta entre o salga del estado "cerca de vencer". - Explica, basándote en el apartado 3, qué diferencia hay entre implementar
hostUpdate()en un controlador y sobreescribirwillUpdate()directamente en la clase del componente, en cuanto al lugar donde vive físicamente el código. - Un compañero de equipo, al ver el controlador del apartado 5, propone que en lugar de
this.host.requestUpdate()sería más simple hacer que el propio host expusiera una propiedad reactivacercaDeVencery que el controlador la asignara directamente. Argumenta, basándote en el apartado 7 y en el propio código del apartado 6, por qué el enfoque actual (el dato vive en el controlador, no en el host) hace que<task-card>necesite cambiar menos código si el controlador se sustituyera en el futuro por una implementación distinta.
Soluciones
export class ContadorTiempoRestanteController {
constructor(host, { margenMs = 24 * 60 * 60 * 1000, intervaloMs = 60000, onCambio } = {}) {
this.host = host;
this.margenMs = margenMs;
this.intervaloMs = intervaloMs;
this.onCambio = onCambio;
this.cercaDeVencer = false;
host.addController(this);
}
_comprobar() {
const nuevoValor = this._calcularSiCercaDeVencer(this.host.fechaLimite);
if (nuevoValor !== this.cercaDeVencer) {
this.cercaDeVencer = nuevoValor;
this.host.requestUpdate();
this.onCambio?.(nuevoValor);
}
}
// hostConnected(), hostDisconnected() y _calcularSiCercaDeVencer() sin cambios.
}this._contadorTiempo = new ContadorTiempoRestanteController(this, {
onCambio: (valor) => console.log(`cercaDeVencer ahora es ${valor}`),
});- Implementar
hostUpdate()en un controlador mantiene ese código físicamente dentro de la clase del controlador, separado por completo de la clase del componente; sobreescribirwillUpdate()directamente en el componente mezcla ese mismo tipo de lógica (algo que debe ejecutarse antes derender()) dentro del propio cuerpo de la claseTaskCard. Ambos se ejecutan en el mismo momento exacto del ciclo, pero solo la versión con controlador permite reutilizar esa lógica en un segundo componente sin copiar código: la versión conwillUpdate()directamente en la clase queda atada a esa clase concreta. - Con el diseño actual,
<task-card>no sabe absolutamente nada sobre cómo el controlador calculacercaDeVencerinternamente: solo conoce quethis._contadorTiempoexpone una propiedadcercaDeVencerlegible enrender(). Si en el futuro se sustituyeraContadorTiempoRestanteControllerpor una implementación distinta (por ejemplo, una que en lugar de unsetIntervalusara alguna API del navegador más eficiente para temporizadores en segundo plano),<task-card>no tendría que cambiar ni una línea, siempre que la nueva implementación siguiera exponiendo la misma propiedadcercaDeVencer. Si, en cambio, el controlador asignara directamentethis.host.cercaDeVencer,<task-card>tendría que seguir declarando esa propiedad en su propiostatic propertiesúnicamente para dar cabida a un detalle de implementación interno del controlador, mezclando la superficie pública de<task-card>con el mecanismo interno de una pieza que, en teoría, debería ser sustituible sin fricciones.
Conclusión
Esta lección ha presentado los controladores reactivos como la alternativa recomendada de Lit para encapsular lógica reutilizable con estado propio y necesidades de ciclo de vida: un objeto corriente de JavaScript, registrado con addController, capaz de engancharse a hostConnected, hostDisconnected, hostUpdate y hostUpdated exactamente en los mismos puntos que ya se conocían como métodos de la propia clase de un componente. El temporizador de <task-card>, hasta ahora atrapado dentro de su propia clase, vive ahora en ContadorTiempoRestanteController, listo para reutilizarse en cualquier otro componente de TaskFlow que necesite el mismo tipo de aviso por proximidad de fecha.
Queda por resolver, sin embargo, cuándo conviene un controlador reactivo y cuándo conviene la alternativa más clásica de JavaScript para este mismo problema general: los mixins. La siguiente lección explica el patrón de los mixins aplicado a Lit, sus limitaciones más frecuentes, y el criterio concreto para elegir entre una técnica y otra según el tipo de comportamiento que se quiera compartir.
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
