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

  1. Custom Elements already had a lifecycle before Lit existed
  2. connectedCallback: when an element enters the DOM
  3. disconnectedCallback: when it leaves, and why cleanup matters
  4. Why you (almost) never need to override the constructor
  5. Comparison table: constructor, connectedCallback, and disconnectedCallback
  6. Real case: warning when a task is close to its deadline
  7. Wrap-up: what's left to explain

  1. 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.

  1. connectedCallback: when an element enters the DOM

connectedCallback 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.

  1. disconnectedCallback: when it leaves, and why cleanup matters

disconnectedCallback 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.

  1. Why you (almost) never need to override the constructor

Anyone 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 constructor runs. A custom element can be created (for example, with document.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 the constructor: it could start for an element that never ends up being displayed, and there is no symmetrical constructor that runs when the object is destroyed so it could be cleaned up (unlike disconnectedCallback, 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, or fechaLimite inside the constructor simply 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'), the constructor is 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 for connectedCallback.

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.

  1. 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.

  1. 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.

  1. 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() or super.disconnectedCallback(): without that call, the internal work LitElement performs 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, the constructor can run for elements that never end up inserted into the DOM, and there is no callback symmetrical to the constructor for cleaning up whatever is activated there; the correct place is always connectedCallback, with its corresponding cleanup in disconnectedCallback.
  • Forgetting disconnectedCallback entirely: this is the most common mistake and the most costly one in the long run; any setInterval, recurring setTimeout, or addEventListener added on window or document (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 connectedCallback runs 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 written connectedCallback that 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 its disconnectedCallback.

Exercises

  1. 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), and inminente (less than 24 hours), storing the result in a state property urgenciaPorFecha of type String instead of a boolean, and adjust render() to show a different message depending on the level.
  2. Explain, based on section 4, why it would have been a mistake to initialize this._idIntervalo (or start the setInterval directly) inside the constructor of <task-card>, even if fechaLimite already had a value assigned at that point.
  3. Add to <task-card> a console.log inside connectedCallback and another inside disconnectedCallback, each showing the task's titulo. Check in the browser, by dynamically inserting and removing a <task-card> from the DOM with JavaScript (for example, from the console with document.body.removeChild(...) and document.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.

  1. Even if fechaLimite already had a valid value at the moment the constructor runs, the problem isn't the value of the property, but whether the element is ever going to be displayed at all. A document.createElement('task-card') followed by a property assignment but without any subsequent appendChild would create an instance with a setInterval running indefinitely in the background, with no disconnectedCallback ever able to stop it (because that callback only fires if the element got connected first). connectedCallback guarantees that the timer only exists while the element is actually in a visible document, and that it always has a corresponding disconnectedCallback able to clean it up.

  2. The expected result is that each call to appendChild on the saved reference triggers a new connectedCallback (with its corresponding console message), and each removeChild triggers a new disconnectedCallback, 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 the constructor.

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

Module 2: Reactive Templates and Rendering

Module 3: Reactive Properties and State

Module 4: Styling Lit Components

Module 5: Events and Component Communication

Module 6: Lifecycle and Advanced Behavior

Module 7: Directives and Advanced Template Features

Module 8: Integration, Interoperability and Deployment

Module 9: Testing and Best Practices

Module 10: Project: Building TaskFlow

© Copyright 2026. All rights reserved