El módulo 5 ha cerrado con una pregunta pendiente: cada vez que estado cambia en <task-card> o tareas cambia en <task-board>, algo ocurre internamente en Lit para que la pantalla se actualice, pero este curso nunca se ha detenido a explicar ese "algo" con precisión. Antes de entrar en los hooks propios de Lit (contenido de la siguiente lección), conviene fijar primero una base más elemental: los callbacks del ciclo de vida que Lit hereda directamente del estándar de Custom Elements, sin añadir nada propio, y que ya se han mencionado de pasada en el módulo 1 sin desarrollarlos. Esta lección los explica en detalle y los aplica a un problema real de TaskFlow: avisar visualmente cuando una tarea está a punto de alcanzar su fechaLimite, usando un temporizador que hay que crear y destruir en el momento correcto.
Contenido
- Custom Elements ya tenía un ciclo de vida antes de que existiera Lit
connectedCallback: cuándo entra un elemento en el DOMdisconnectedCallback: cuándo sale, y por qué limpiar importa- Por qué (casi) nunca hace falta sobreescribir el
constructor - Tabla comparativa: constructor, connectedCallback y disconnectedCallback
- Caso real: avisar cuando una tarea está cerca de su fecha límite
- Cierre: lo que falta por explicar
- Custom Elements ya tenía un ciclo de vida antes de que existiera Lit
Todo lo que se explica en esta lección no es una característica de Lit: es parte de la especificación estándar de Custom Elements, la misma API del navegador sobre la que Lit se construye, mencionada ya en la primera lección del curso. Cualquier clase que extienda HTMLElement (con o sin Lit de por medio) puede declarar hasta cuatro métodos especiales, con nombres reservados, que el navegador llama automáticamente en momentos concretos de la vida de un elemento personalizado: constructor, connectedCallback, disconnectedCallback y attributeChangedCallback (este último, gestionado casi siempre internamente por Lit para sincronizar atributos y propiedades, como se vio en el módulo 3, y rara vez hace falta tocarlo a mano).
Esta lección se centra en los tres primeros. Son "callbacks" en el sentido más literal: funciones que el propio navegador invoca por su cuenta, en respuesta a que el elemento se cree, se inserte en un documento o se elimine de él; el desarrollador no los llama nunca directamente, solo los sobreescribe para añadir código que deba ejecutarse en esos momentos concretos.
connectedCallback: cuándo entra un elemento en el DOM
connectedCallback: cuándo entra un elemento en el DOMconnectedCallback se ejecuta cada vez que el elemento se inserta en un documento con capacidad de renderizado (normalmente, el DOM de la página visible en el navegador). La palabra "cada vez" es deliberada: no es un evento que ocurra una única vez en la vida de un elemento, sino potencialmente varias, porque un elemento puede quitarse del DOM y volver a insertarse más adelante (por ejemplo, si algún código de la aplicación mueve un nodo de un contenedor a otro con appendChild), y cada una de esas inserciones dispara de nuevo connectedCallback.
class TaskCard extends LitElement {
connectedCallback() {
super.connectedCallback();
console.log('task-card insertada en el DOM');
}
}El primer detalle que merece atención es la llamada a super.connectedCallback(). LitElement ya tiene su propia implementación de connectedCallback, que realiza trabajo interno imprescindible (entre otras cosas, programa la primera actualización del componente si todavía no se ha renderizado). Sobreescribir connectedCallback sin llamar a super.connectedCallback() primero rompería ese trabajo interno, así que la convención, sin excepción, es llamar siempre a super.connectedCallback() como primera línea del método, antes de añadir cualquier código propio.
connectedCallback es el lugar recomendado para cualquier inicialización que dependa de que el elemento esté realmente conectado a un documento: arrancar temporizadores, añadir listeners a objetos externos al propio componente (por ejemplo, a window o a document), abrir una conexión, o cualquier otro efecto que solo tenga sentido mientras el componente está visible y activo.
disconnectedCallback: cuándo sale, y por qué limpiar importa
disconnectedCallback: cuándo sale, y por qué limpiar importadisconnectedCallback es el complemento exacto de connectedCallback: se ejecuta cada vez que el elemento se retira de un documento con capacidad de renderizado, ya sea porque se elimina definitivamente o porque, como se ha señalado en el apartado anterior, se mueve de un lugar a otro del DOM (una operación de movimiento se traduce, internamente, en una desconexión seguida de una reconexión).
class TaskCard extends LitElement {
disconnectedCallback() {
super.disconnectedCallback();
console.log('task-card retirada del DOM');
}
}La razón por la que este callback importa tanto en la práctica es la limpieza de recursos: todo lo que se active en connectedCallback y no dependa exclusivamente del propio ciclo de vida de Lit debe desactivarse explícitamente en disconnectedCallback. Un temporizador arrancado con setInterval en connectedCallback y nunca detenido seguirà ejecutándose indefinidamente, incluso después de que el elemento haya desaparecido del DOM y, en apariencia, "ya no exista"; mientras el intervalo siga activo, el motor de JavaScript mantiene viva una referencia al objeto (a través del cierre de la función que se ejecuta en cada tick), y ese objeto no puede liberarse de memoria. Esto es una fuga de memoria en el sentido más clásico del término, y es exactamente el tipo de error que disconnectedCallback existe para evitar.
Igual que con connectedCallback, la convención es llamar siempre a super.disconnectedCallback(), para no interferir con la limpieza interna que LitElement realiza por su cuenta.
- Por qué (casi) nunca hace falta sobreescribir el
constructor
constructorQuien llega a Lit desde otros lenguajes o frameworks orientados a objetos tiende a usar el constructor como el lugar natural para inicializar cualquier cosa. En Lit, sin embargo, el constructor tiene dos limitaciones importantes que hacen que, en la inmensa mayoría de los casos, no sea el sitio adecuado:
- El elemento todavía no está conectado al DOM cuando se ejecuta el
constructor. Un elemento personalizado puede crearse (por ejemplo, condocument.createElement('task-card')) mucho antes de insertarse en ningún sitio, o incluso sin llegar a insertarse nunca. Cualquier inicialización que dependa de que el elemento esté realmente visible en una página —como el temporizador de esta lección— arrancaría en el momento equivocado si viviera en elconstructor: podría arrancar para un elemento que jamás llega a mostrarse, y no existe ningúnconstructorsimétrico que se ejecute al destruir el objeto para poder limpiarlo (a diferencia dedisconnectedCallback, que sí existe para ese propósito). - Los valores por defecto de las propiedades reactivas ya cubren la inicialización simple. Todos los ejemplos de TaskFlow desde el módulo 3 inicializan
titulo,estado,prioridadofechaLimitedentro delconstructorúnicamente porque ahí es donde, por convención de JavaScript, se asignan valores a campos de instancia antes de que la clase esté lista; pero no hay ninguna lógica de ciclo de vida en juego en esas asignaciones, solo valores iniciales llanos. Cuando la inicialización es así de simple (this.estado = 'pendiente'), elconstructorsigue siendo perfectamente adecuado; el problema aparece cuando esa inicialización implica efectos activos —temporizadores, suscripciones, listeners externos—, porque ahí sí hace falta esperar aconnectedCallback.
La regla práctica que se sigue en TaskFlow, y que resume bien el criterio general de Lit, es esta: el constructor inicializa valores; connectedCallback inicia efectos. Si una propiedad solo necesita un valor inicial razonable, sigue yendo en el constructor. Si una operación implica algo que sigue "vivo" mientras el componente esté en pantalla (y que hay que apagar cuando deje de estarlo), pertenece a la pareja connectedCallback/disconnectedCallback, nunca al constructor en solitario.
- Tabla comparativa: constructor, connectedCallback y disconnectedCallback
| Callback | ¿Cuándo se ejecuta? | ¿Cuántas veces? | ¿El elemento está en el DOM? | Uso típico |
|---|---|---|---|---|
constructor |
Al crear la instancia de la clase | Una sola vez en toda la vida del objeto | No necesariamente | Asignar valores iniciales a campos y propiedades |
connectedCallback |
Al insertarse en un documento renderizable | Una o más veces (cada inserción, incluidas las reinserciones) | Sí | Arrancar temporizadores, suscripciones o listeners externos |
disconnectedCallback |
Al retirarse de un documento renderizable | Una vez por cada connectedCallback correspondiente |
Ya no (se acaba de retirar) | Detener y liberar todo lo que se activó en connectedCallback |
Esta tabla deja ver un principio de simetría que conviene interiorizar: cualquier cosa que se active dentro de connectedCallback debería tener su contraparte exacta, desactivándola, dentro de disconnectedCallback. Es la misma disciplina de "quien abre, cierra" que aparece en muchos otros contextos de programación (abrir y cerrar un fichero, adquirir y liberar un bloqueo), aplicada aquí al ciclo de vida de un componente.
- Caso real: avisar cuando una tarea está cerca de su fecha límite
TaskFlow ya tiene, desde el módulo 3, una propiedad fechaLimite en <task-card>, convertida a un objeto Date real gracias al conversor personalizado visto en aquel momento. Hasta ahora, esa fecha solo se mostraba como texto; esta lección la aprovecha para algo más útil: resaltar visualmente la tarjeta cuando falta menos de un día para que se cumpla.
Como el paso del tiempo no depende de que ninguna propiedad reactiva cambie (una tarea puede pasar de "lejos de vencer" a "cerca de vencer" sin que nadie modifique fechaLimite ni ninguna otra propiedad; simplemente pasan los minutos), no basta con recalcular esta situación cuando cambie algún dato: hace falta comprobarlo periódicamente con un temporizador, y ese temporizador es exactamente el tipo de "efecto activo" que pertenece a connectedCallback/disconnectedCallback, no al constructor.
// src/components/task-card.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' },
cercaDeVencer: { state: true },
};
constructor() {
super();
this.titulo = '';
this.estado = 'pendiente';
this.prioridad = 1;
this.urgente = false;
this.expandida = false;
this.fechaLimite = null;
this.cercaDeVencer = false;
}
connectedCallback() {
super.connectedCallback();
// Comprueba de inmediato, y a partir de ahí cada minuto,
// si la tarea ha entrado en la ventana de "cerca de vencer".
this.cercaDeVencer = this._calcularSiCercaDeVencer();
this._idIntervalo = setInterval(() => {
this.cercaDeVencer = this._calcularSiCercaDeVencer();
}, 60000);
}
disconnectedCallback() {
super.disconnectedCallback();
clearInterval(this._idIntervalo);
}
_calcularSiCercaDeVencer() {
if (!this.fechaLimite) {
return false;
}
const unDiaEnMs = 24 * 60 * 60 * 1000;
const msRestantes = this.fechaLimite.getTime() - Date.now();
return msRestantes > 0 && msRestantes <= unDiaEnMs;
}
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.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>
`;
}
}Varios detalles merecen comentario. Primero, cercaDeVencer se declara como estado interno ({ state: true }), no como propiedad pública: es un dato calculado dentro del propio componente a partir de fechaLimite y del reloj del sistema, no algo que ningún componente externo deba asignar directamente, exactamente el mismo criterio de expandida visto en el módulo 3. Segundo, _calcularSiCercaDeVencer es un método auxiliar corriente, sin nada especial de Lit, que aísla la lógica de cálculo del resto del código y facilita reutilizarla tanto en connectedCallback (para la comprobación inicial) como dentro del propio intervalo. Tercero, y más importante para esta lección: setInterval se arranca en connectedCallback, nunca en el constructor, precisamente porque no tendría sentido que una <task-card> creada pero nunca insertada en ninguna página mantuviera un temporizador corriendo indefinidamente sin ningún efecto visible; y se detiene con clearInterval en disconnectedCallback, para que, si la tarjeta se elimina de TaskFlow (por ejemplo, en un futuro ejercicio de eliminar tareas), el temporizador deje de ejecutarse y no queden referencias colgando en memoria.
El resultado, en la práctica de TaskFlow, es que cualquier tarjeta con una fechaLimite fijada para dentro de menos de 24 horas mostrará automáticamente el aviso "⏰ Está a punto de vencer", sin que nadie tenga que refrescar la página ni cambiar ninguna propiedad manualmente: el propio paso del tiempo, vigilado por el intervalo, hace que cercaDeVencer pase de false a true en el momento adecuado.
- Cierre: lo que falta por explicar
Con connectedCallback y disconnectedCallback ya dominados, <task-card> tiene su primer efecto real ligado al paso del tiempo, correctamente iniciado y correctamente limpiado. Pero estos dos callbacks, heredados del estándar de Custom Elements, no son los únicos puntos de enganche que ofrece Lit sobre el ciclo de una actualización: quedan por ver los hooks que Lit añade específicamente sobre su propio proceso de renderizado, capaces de responder no a "el elemento ha entrado o salido del DOM", sino a "una propiedad reactiva ha cambiado y se va a volver a ejecutar render()". Ese es el contenido de la siguiente lección.
Errores Comunes y Consejos
- Olvidar
super.connectedCallback()osuper.disconnectedCallback(): sin esa llamada, se pierde el trabajo interno queLitElementrealiza en esos mismos callbacks (entre otras cosas, la programación de la primera actualización), lo que puede producir errores sutiles y difíciles de rastrear, como un componente que nunca llega a renderizarse la primera vez. - Arrancar un temporizador, una suscripción o un listener externo en el
constructor: como se explicó en el apartado 4, elconstructorpuede ejecutarse para elementos que nunca llegan a insertarse en el DOM, y no existe ningún callback simétrico alconstructorpara limpiar lo que allí se active; el lugar correcto es siempreconnectedCallback, con su limpieza correspondiente endisconnectedCallback. - Olvidar
disconnectedCallbackpor completo: es el error más habitual y el más costoso a largo plazo; cualquiersetInterval,setTimeoutrecurrente, oaddEventListenerañadido sobrewindowodocument(que no se limpia solo cuando el componente desaparece, a diferencia de los listeners puestos sobre el propio Shadow DOM del componente) debe desactivarse explícitamente, o la aplicación acumulará fugas de memoria cada vez que se creen y destruyan componentes. - Asumir que
connectedCallbackse ejecuta una sola vez en la vida del componente: como se ha explicado en el apartado 2, un elemento puede desconectarse y reconectarse varias veces (por ejemplo, al moverlo de un contenedor a otro); unconnectedCallbackmal escrito, que no comprueba si ya existe un intervalo previo antes de crear uno nuevo, podría acabar arrancando temporizadores duplicados si no se empareja correctamente con sudisconnectedCallback.
Ejercicios
- Modifica
_calcularSiCercaDeVencer()para que, en lugar de un único umbral de "menos de 24 horas", distinga tres niveles:lejos(más de 3 días),proxima(entre 3 días y 24 horas) yinminente(menos de 24 horas), guardando el resultado en una propiedad de estadourgenciaPorFechade tipoStringen lugar de un booleano, y ajustarender()para mostrar un mensaje distinto según el nivel. - Explica, basándote en el apartado 4, por qué habría sido un error inicializar
this._idIntervalo(o arrancar directamente elsetInterval) dentro delconstructorde<task-card>, incluso aunquefechaLimiteya tuviera un valor asignado en ese momento. - Añade a
<task-card>unconsole.logdentro deconnectedCallbacky otro dentro dedisconnectedCallback, cada uno indicando eltitulode la tarea. Comprueba en el navegador, insertando y eliminando dinámicamente una<task-card>del DOM con JavaScript (por ejemplo, desde la consola condocument.body.removeChild(...)ydocument.body.appendChild(...)sobre la misma referencia), que ambos mensajes se disparan en cada ciclo de inserción y retirada, no solo una vez.
Soluciones
static properties = {
// ...resto de propiedades...
urgenciaPorFecha: { state: true },
};
_calcularUrgenciaPorFecha() {
if (!this.fechaLimite) {
return 'lejos';
}
const unDiaEnMs = 24 * 60 * 60 * 1000;
const msRestantes = this.fechaLimite.getTime() - Date.now();
if (msRestantes <= 0) {
return 'lejos'; // ya venció; no tiene sentido seguir avisando
}
if (msRestantes <= unDiaEnMs) {
return 'inminente';
}
if (msRestantes <= 3 * unDiaEnMs) {
return 'proxima';
}
return 'lejos';
}
connectedCallback() {
super.connectedCallback();
this.urgenciaPorFecha = this._calcularUrgenciaPorFecha();
this._idIntervalo = setInterval(() => {
this.urgenciaPorFecha = this._calcularUrgenciaPorFecha();
}, 60000);
}En render(), un simple if/else encadenado (o un pequeño objeto de mensajes indexado por el valor de urgenciaPorFecha) sustituiría al aviso único de cercaDeVencer.
-
Aunque
fechaLimiteya tuviera un valor válido en el momento de ejecutarse elconstructor, el problema no está en el valor de la propiedad, sino en si el elemento va a llegar a mostrarse alguna vez. Undocument.createElement('task-card')seguido de una asignación de propiedades pero sin ningúnappendChildposterior crearía una instancia con unsetIntervalcorriendo indefinidamente en segundo plano, sin ningúndisconnectedCallbackque pudiera detenerlo jamás (porque ese callback solo se dispara si el elemento llegó a conectarse primero).connectedCallbackgarantiza que el temporizador solo existe mientras el elemento está realmente en un documento visible, y que tiene siempre undisconnectedCallbackcorrespondiente capaz de limpiarlo. -
El resultado esperado es que cada llamada a
appendChildsobre la referencia guardada dispare un nuevoconnectedCallback(con su correspondiente mensaje de consola), y cadaremoveChilddispare un nuevodisconnectedCallback, tantas veces como se repita la operación. Esto confirma de forma práctica lo señalado en el apartado 2: estos callbacks no son eventos de "una sola vez en la vida del objeto", sino que se disparan en cada transición de conexión y desconexión del DOM, por eso la lógica de arranque y parada del intervalo debe vivir exactamente en esa pareja de callbacks y no en elconstructor.
Conclusión
Esta lección ha explicado en detalle tres piezas del ciclo de vida de cualquier elemento personalizado —constructor, connectedCallback y disconnectedCallback— heredadas directamente del estándar de Custom Elements, sin nada específico de Lit de por medio. Se ha fijado la regla práctica de que el constructor inicializa valores simples, mientras que cualquier efecto activo (temporizadores, suscripciones, listeners externos) pertenece a la pareja connectedCallback/disconnectedCallback, con una limpieza simétrica y obligatoria para evitar fugas de memoria. <task-card> ya usa este patrón para avisar automáticamente cuando una tarea está cerca de su fechaLimite, sin necesidad de que ninguna otra propiedad cambie para disparar el aviso.
Quedan, sin embargo, los hooks que Lit añade sobre su propio proceso de actualización, capaces de reaccionar específicamente a que una propiedad reactiva haya cambiado: willUpdate, firstUpdated, updated y la promesa updateComplete. Esa es la siguiente pieza del módulo 6, y con ella se completará por fin la explicación de cuándo, exactamente, ocurre una actualización de Lit y en qué orden se ejecutan sus distintas fases.
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
