Module 5 closed with an open question: every time estado changes on <task-card> or tareas changes on <task-board>, something happens internally in Lit so that the screen updates, but this course has never stopped to explain that "something" with precision. Before moving on to Lit's own hooks (the content of the next lesson), it's worth first laying a more elementary foundation: the lifecycle callbacks that Lit inherits directly from the Custom Elements standard, without adding anything of its own, and which were already mentioned in passing in module 1 without being developed further. This lesson explains them in detail and applies them to a real TaskFlow problem: visually warning when a task is about to reach its fechaLimite, using a timer that must be created and destroyed at the right moment.
Contents
- Custom Elements already had a lifecycle before Lit existed
connectedCallback: when an element enters the DOMdisconnectedCallback: when it leaves, and why cleanup matters- Why you (almost) never need to override the
constructor - Comparison table: constructor, connectedCallback, and disconnectedCallback
- Real case: warning when a task is close to its deadline
- Wrap-up: what's left to explain
- Custom Elements already had a lifecycle before Lit existed
Everything explained in this lesson is not a feature of Lit: it is part of the standard Custom Elements specification, the same browser API that Lit is built on top of, already mentioned in the course's first lesson. Any class that extends HTMLElement (with or without Lit involved) can declare up to four special methods, with reserved names, that the browser calls automatically at specific moments in the life of a custom element: constructor, connectedCallback, disconnectedCallback, and attributeChangedCallback (the latter, almost always managed internally by Lit to synchronize attributes and properties, as seen in module 3, and rarely needs to be touched by hand).
This lesson focuses on the first three. They are "callbacks" in the most literal sense: functions that the browser itself invokes on its own, in response to the element being created, inserted into a document, or removed from it; the developer never calls them directly, only overrides them to add code that must run at those specific moments.
connectedCallback: when an element enters the DOM
connectedCallback: when an element enters the DOMconnectedCallback runs every time the element is inserted into a document capable of rendering (typically, the DOM of the page visible in the browser). The phrase "every time" is deliberate: it is not an event that happens only once in an element's life, but potentially several times, because an element can be removed from the DOM and inserted again later (for example, if some application code moves a node from one container to another with appendChild), and each of those insertions triggers connectedCallback again.
class TaskCard extends LitElement {
connectedCallback() {
super.connectedCallback();
console.log('task-card insertada en el DOM');
}
}The first detail worth noting is the call to super.connectedCallback(). LitElement already has its own implementation of connectedCallback, which performs essential internal work (among other things, it schedules the component's first update if it hasn't rendered yet). Overriding connectedCallback without first calling super.connectedCallback() would break that internal work, so the convention, without exception, is to always call super.connectedCallback() as the first line of the method, before adding any of your own code.
connectedCallback is the recommended place for any initialization that depends on the element actually being connected to a document: starting timers, adding listeners on objects external to the component itself (for example, on window or document), opening a connection, or any other effect that only makes sense while the component is visible and active.
disconnectedCallback: when it leaves, and why cleanup matters
disconnectedCallback: when it leaves, and why cleanup mattersdisconnectedCallback is the exact counterpart of connectedCallback: it runs every time the element is removed from a document capable of rendering, whether because it is permanently deleted or because, as noted in the previous section, it is moved from one place to another in the DOM (a move operation translates, internally, into a disconnection followed by a reconnection).
class TaskCard extends LitElement {
disconnectedCallback() {
super.disconnectedCallback();
console.log('task-card retirada del DOM');
}
}The reason this callback matters so much in practice is resource cleanup: anything activated in connectedCallback that doesn't depend exclusively on Lit's own lifecycle must be explicitly deactivated in disconnectedCallback. A timer started with setInterval in connectedCallback and never stopped will keep running indefinitely, even after the element has disappeared from the DOM and, apparently, "no longer exists"; as long as the interval remains active, the JavaScript engine keeps a reference to the object alive (through the closure of the function that runs on each tick), and that object cannot be freed from memory. This is a memory leak in the most classic sense of the term, and it is exactly the kind of error that disconnectedCallback exists to prevent.
Just as with connectedCallback, the convention is to always call super.disconnectedCallback(), so as not to interfere with the internal cleanup that LitElement performs on its own.
- Why you (almost) never need to override the
constructor
constructorAnyone coming to Lit from other object-oriented languages or frameworks tends to use the constructor as the natural place to initialize anything. In Lit, however, the constructor has two important limitations that mean it is not the right place in the vast majority of cases:
- The element is not yet connected to the DOM when the
constructorruns. A custom element can be created (for example, withdocument.createElement('task-card')) long before it's inserted anywhere, or even without ever being inserted at all. Any initialization that depends on the element actually being visible on a page —like this lesson's timer— would start at the wrong moment if it lived in theconstructor: it could start for an element that never ends up being displayed, and there is no symmetricalconstructorthat runs when the object is destroyed so it could be cleaned up (unlikedisconnectedCallback, which does exist for that purpose). - The default values of reactive properties already cover simple initialization. All the TaskFlow examples since module 3 initialize
titulo,estado,prioridad, orfechaLimiteinside theconstructorsimply because that's where, by JavaScript convention, values are assigned to instance fields before the class is ready; but there is no lifecycle logic at play in those assignments, only plain initial values. When initialization is this simple (this.estado = 'pendiente'), theconstructoris still perfectly adequate; the problem arises when that initialization involves active effects —timers, subscriptions, external listeners— because that's when you do need to wait forconnectedCallback.
The practical rule followed in TaskFlow, which summarizes Lit's general criterion well, is this: the constructor initializes values; connectedCallback starts effects. If a property only needs a reasonable initial value, it still belongs in the constructor. If an operation involves something that stays "alive" while the component is on screen (and that must be turned off when it stops being so), it belongs to the connectedCallback/disconnectedCallback pair, never to the constructor alone.
- Comparison table: constructor, connectedCallback, and disconnectedCallback
| Callback | When does it run? | How many times? | Is the element in the DOM? | Typical use |
|---|---|---|---|---|
constructor |
When the class instance is created | Only once in the object's entire life | Not necessarily | Assigning initial values to fields and properties |
connectedCallback |
When inserted into a renderable document | One or more times (each insertion, including reinsertions) | Yes | Starting timers, subscriptions, or external listeners |
disconnectedCallback |
When removed from a renderable document | Once for each corresponding connectedCallback |
No longer (it has just been removed) | Stopping and releasing everything activated in connectedCallback |
This table reveals a principle of symmetry worth internalizing: anything activated inside connectedCallback should have its exact counterpart, deactivating it, inside disconnectedCallback. It's the same "whoever opens, closes" discipline that appears in many other programming contexts (opening and closing a file, acquiring and releasing a lock), applied here to a component's lifecycle.
- Real case: warning when a task is close to its deadline
TaskFlow has had, since module 3, a fechaLimite property on <task-card>, converted into a real Date object thanks to the custom converter seen at that time. Until now, that date was only shown as text; this lesson puts it to more useful work: visually highlighting the card when less than a day remains before it's due.
Since the passage of time doesn't depend on any reactive property changing (a task can go from "far from due" to "close to due" without anyone modifying fechaLimite or any other property; minutes simply pass), it isn't enough to recalculate this situation whenever some data changes: it needs to be checked periodically with a timer, and that timer is exactly the kind of "active effect" that belongs to connectedCallback/disconnectedCallback, not to the 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();
// Check immediately, and from then on every minute,
// whether the task has entered the "close to due" window.
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>
`;
}
}Several details deserve comment. First, cercaDeVencer is declared as internal state ({ state: true }), not as a public property: it's a value computed inside the component itself from fechaLimite and the system clock, not something any external component should assign directly, exactly the same criterion as expandida seen in module 3. Second, _calcularSiCercaDeVencer is an ordinary helper method, with nothing special about Lit, that isolates the calculation logic from the rest of the code and makes it easy to reuse both in connectedCallback (for the initial check) and inside the interval itself. Third, and most important for this lesson: setInterval is started in connectedCallback, never in the constructor, precisely because it wouldn't make sense for a <task-card> that was created but never inserted into any page to keep a timer running indefinitely with no visible effect; and it's stopped with clearInterval in disconnectedCallback, so that if the card is removed from TaskFlow (for example, in a future exercise on deleting tasks), the timer stops running and no dangling references are left in memory.
The result, in TaskFlow's practice, is that any card with a fechaLimite set for less than 24 hours away will automatically show the "⏰ Está a punto de vencer" warning, without anyone having to refresh the page or manually change any property: the passage of time itself, watched over by the interval, makes cercaDeVencer switch from false to true at the right moment.
- Wrap-up: what's left to explain
With connectedCallback and disconnectedCallback now mastered, <task-card> has its first real effect tied to the passage of time, correctly started and correctly cleaned up. But these two callbacks, inherited from the Custom Elements standard, are not the only hook points Lit offers into the cycle of an update: still to be covered are the hooks Lit adds specifically on top of its own rendering process, able to respond not to "the element has entered or left the DOM," but to "a reactive property has changed and render() is about to run again." That's the content of the next lesson.
Common Mistakes and Tips
- Forgetting
super.connectedCallback()orsuper.disconnectedCallback(): without that call, the internal workLitElementperforms in those same callbacks is lost (among other things, scheduling the first update), which can produce subtle, hard-to-trace bugs, such as a component that never manages to render for the first time. - Starting a timer, a subscription, or an external listener in the
constructor: as explained in section 4, theconstructorcan run for elements that never end up inserted into the DOM, and there is no callback symmetrical to theconstructorfor cleaning up whatever is activated there; the correct place is alwaysconnectedCallback, with its corresponding cleanup indisconnectedCallback. - Forgetting
disconnectedCallbackentirely: this is the most common mistake and the most costly one in the long run; anysetInterval, recurringsetTimeout, oraddEventListeneradded onwindowordocument(which doesn't clean itself up when the component disappears, unlike listeners placed on the component's own Shadow DOM) must be explicitly deactivated, or the application will accumulate memory leaks every time components are created and destroyed. - Assuming
connectedCallbackruns only once in a component's life: as explained in section 2, an element can be disconnected and reconnected several times (for example, when moved from one container to another); a poorly writtenconnectedCallbackthat doesn't check whether an interval already exists before creating a new one could end up starting duplicate timers if it isn't correctly paired with itsdisconnectedCallback.
Exercises
- Modify
_calcularSiCercaDeVencer()so that, instead of a single "less than 24 hours" threshold, it distinguishes three levels:lejos(more than 3 days),proxima(between 3 days and 24 hours), andinminente(less than 24 hours), storing the result in a state propertyurgenciaPorFechaof typeStringinstead of a boolean, and adjustrender()to show a different message depending on the level. - Explain, based on section 4, why it would have been a mistake to initialize
this._idIntervalo(or start thesetIntervaldirectly) inside theconstructorof<task-card>, even iffechaLimitealready had a value assigned at that point. - Add to
<task-card>aconsole.loginsideconnectedCallbackand another insidedisconnectedCallback, each showing the task'stitulo. Check in the browser, by dynamically inserting and removing a<task-card>from the DOM with JavaScript (for example, from the console withdocument.body.removeChild(...)anddocument.body.appendChild(...)on the same reference), that both messages fire on every insertion and removal cycle, not just once.
Solutions
static properties = {
// ...rest of the properties...
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'; // already overdue; no point continuing to warn
}
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);
}In render(), a simple chained if/else (or a small message object indexed by the value of urgenciaPorFecha) would replace the single cercaDeVencer warning.
-
Even if
fechaLimitealready had a valid value at the moment theconstructorruns, the problem isn't the value of the property, but whether the element is ever going to be displayed at all. Adocument.createElement('task-card')followed by a property assignment but without any subsequentappendChildwould create an instance with asetIntervalrunning indefinitely in the background, with nodisconnectedCallbackever able to stop it (because that callback only fires if the element got connected first).connectedCallbackguarantees that the timer only exists while the element is actually in a visible document, and that it always has a correspondingdisconnectedCallbackable to clean it up. -
The expected result is that each call to
appendChildon the saved reference triggers a newconnectedCallback(with its corresponding console message), and eachremoveChildtriggers a newdisconnectedCallback, as many times as the operation is repeated. This practically confirms what was noted in section 2: these callbacks are not "once in the object's lifetime" events, but rather fire on every DOM connection and disconnection transition, which is why the logic for starting and stopping the interval must live exactly in that pair of callbacks and not in theconstructor.
Conclusion
This lesson has explained in detail three pieces of the lifecycle of any custom element —constructor, connectedCallback, and disconnectedCallback— inherited directly from the Custom Elements standard, with nothing Lit-specific involved. It has established the practical rule that the constructor initializes simple values, while any active effect (timers, subscriptions, external listeners) belongs to the connectedCallback/disconnectedCallback pair, with symmetrical, mandatory cleanup to avoid memory leaks. <task-card> already uses this pattern to automatically warn when a task is close to its fechaLimite, without needing any other property to change to trigger the warning.
Still remaining, however, are the hooks Lit adds on top of its own update process, capable of reacting specifically to a reactive property having changed: willUpdate, firstUpdated, updated, and the updateComplete promise. That's the next piece of module 6, and with it the explanation of when, exactly, a Lit update occurs and in what order its different phases run will finally be complete.
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
