The previous two lessons left <task-card> with a self-contained piece of logic trapped inside its own class: a timer that starts in connectedCallback, stops in disconnectedCallback, and a calculation (_calcularSiCercaDeVencer) shared by both. It works perfectly for <task-card>, but if tomorrow <task-board> needed to show, say, a summary with the number of tasks close to their deadline, that same logic would have to be copied and pasted into a second class, with the risk that the two copies end up diverging over time. This lesson presents the tool Lit offers specifically for this problem: reactive controllers, reusable objects capable of hooking into any component's lifecycle without needing to inherit from it.

Contents

  1. The problem: logic with its own lifecycle, trapped in a class
  2. What a ReactiveController is
  3. A controller's interface: hostConnected, hostDisconnected, hostUpdate, hostUpdated
  4. addController: registering a controller on its host
  5. Extracting <task-card>'s timer into a reusable controller
  6. Using the controller from <task-card>
  7. Why a controller and not just a helper function
  8. Wrap-up: controllers versus mixins

  1. The problem: logic with its own lifecycle, trapped in a class

The logic added in lesson 06-01 has a particular characteristic that sets it apart from a simple helper method: it needs to hook into the lifecycle of the component using it. It's not enough to extract _calcularSiCercaDeVencer into a standalone function (that alone would be trivial, requiring no new concept); the underlying problem is that the timer itself —when it starts, when it stops— depends on when <task-card> connects to and disconnects from the DOM, and that dependency on the lifecycle is precisely what makes copying and pasting this logic into a second component just as tempting as it is error-prone: any future fix (for example, adjusting the "close to due" margin from 24 hours to 48) would have to be applied separately to every copy.

  1. What a ReactiveController is

A ReactiveController is, in its simplest form, an ordinary JavaScript object (it doesn't need to inherit from any Lit class) that implements one or more of a small set of functions with reserved names, and that registers explicitly on a Lit component (called, in this relationship, its host) to receive notifications at the same key lifecycle moments already studied in this module's previous lessons.

The central idea, and the one that solves the problem from section 1, is that the same controller —a single JavaScript class— can be registered on as many different hosts as needed, each with its own controller instance, without any of them needing to inherit from a common base class or duplicate code: the controller encapsulates both the state (for example, the identifier of the active interval) and the behavior (starting it, stopping it, computing the result), and the component using it simply keeps a reference to the controller and reads, from its render(), the value the controller exposes.

  1. A controller's interface: hostConnected, hostDisconnected, hostUpdate, hostUpdated

A controller can implement any of these methods, all optional, and Lit calls them automatically at the corresponding moment in its host's lifecycle:

Controller method Called when the host runs... Conceptual equivalent seen in this module
hostConnected() connectedCallback() Lesson 06-01: starting an active effect
hostDisconnected() disconnectedCallback() Lesson 06-01: cleaning up that effect
hostUpdate() Before render(), on every update Lesson 06-02: equivalent to willUpdate
hostUpdated() After every render(), with the DOM already updated Lesson 06-02: equivalent to updated

This correspondence is no accident: reactive controllers are deliberately designed as an alternative path for hooking into the exact same lifecycle points already studied as methods of the component's own class, with the difference that, instead of living mixed inside the component's class, they live in a separate object, independently reusable.

  1. addController: registering a controller on its host

For a controller to start receiving these notifications, its host —any LitElement, with no additional configuration needed— must register it explicitly with this.addController(controlador), typically inside the component's constructor:

class TaskCard extends LitElement {
  constructor() {
    super();
    this._miControlador = new AlgunControlador(this);
  }
}

It's common, as in this example, for the controller's own constructor to receive the host as an argument and call host.addController(this) itself internally, so that instantiating the controller and registering it becomes a single step, as will be seen in the next section. From that registration onward, Lit takes care of invoking the methods from the table above at the right moment, exactly as if they were methods of the component's own class.

  1. Extracting <task-card>'s timer into a reusable controller

With the interface now clear, the logic from lesson 06-01 moves, with almost no conceptual changes, into a standalone class:

// 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;
  }
}

Several details set this code apart from the original in lesson 06-01, and all of them respond to the same need: making it reusable by any host, not just <task-card>.

  • The constructor stores a reference to the host received as a parameter, and calls host.addController(this) itself: whoever uses this controller doesn't need to remember to call addController separately, it's enough to instantiate it passing this (the component itself) as the argument.
  • The "close to due" margin (margenMs) and the check frequency (intervaloMs) have become configurable parameters, with reasonable defaults, instead of being hardcoded as in lesson 06-01: this lets a future second host use, say, a 48-hour margin without having to copy and modify the controller.
  • The controller reads this.host.fechaLimite directly, assuming that any host using it will expose a property with that name. This is the only assumption the controller makes about its host, and it's worth documenting clearly: a reusable controller always needs some minimal contract for what its host must offer it.
  • Fundamental: instead of directly assigning a reactive property on the host (this.host.cercaDeVencer = ..., as <task-card> did in lesson 06-01), the controller stores its own result in a field of its own (this.cercaDeVencer, on the controller itself, not on the host) and explicitly calls this.host.requestUpdate() to ask Lit to run render() again. The controller doesn't need to declare any Lit reactive property for its own internal state; requestUpdate() is enough, exactly the same low-level mechanism studied in module 2, to trigger an update every time its result changes.

  1. Using the controller from <task-card>

With the controller already extracted, <task-card> becomes noticeably simpler: connectedCallback, disconnectedCallback, and _calcularSiCercaDeVencer from lesson 06-01 disappear, replaced by a single controller instance.

// 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>
    `;
  }
}

Notice that cercaDeVencer no longer exists as a state property of <task-card> (it has disappeared from static properties): it now lives exclusively inside the controller, and render() reads it directly as this._contadorTiempo.cercaDeVencer. <task-card> doesn't even need to know that, internally, that value is updated via a setInterval; it only needs to know that the controller exposes a cercaDeVencer property readable at any time, and that Lit will automatically run render() again whenever that value changes, thanks to the requestUpdate() the controller itself invokes.

If <task-board> later needed to show how many tasks are close to their deadline, it could instantiate its own ContadorTiempoRestanteController for each task in this.tareas (or, more simply, reuse the same calculation by aggregating it from several instances already created in each <task-card>), without duplicating a single line of the timer logic: the controller is already written, tested, and ready to use on any host that exposes a fechaLimite property.

  1. Why a controller and not just a helper function

One might well ask why an object with this specific interface is needed, instead of simply extracting _calcularSiCercaDeVencer into a standalone function and importing it wherever needed. The answer lies in what a standalone function cannot solve on its own: the lifecycle and the state that persists between calls. A stateless helper function would work perfectly for the one-off calculation (_calcularSiCercaDeVencer(fechaLimite) already was one, in fact, since lesson 06-01), but it cannot, by itself, start a timer when the host connects, stop it when it disconnects, or remember that timer's identifier from one call to the next. That requires an object with its own state (_idIntervalo, the current cercaDeVencer) and with hooks into the host's lifecycle —exactly what the ReactiveController interface provides in standardized form, instead of every component reinventing its own way of coordinating timers with connectedCallback and disconnectedCallback.

  1. Wrap-up: controllers versus mixins

Reactive controllers are, in Lit's documentation and in the ecosystem's common practice, the recommended alternative to another, older and more general JavaScript technique for composing reusable behavior between classes: mixins. Both tools pursue the same general goal (reusing logic across several components without copying and pasting code), but they do so in ways different enough to deserve a full comparison before deciding which to use in each case. That comparison, along with the mixin pattern itself, is the content of the next lesson.

Common Mistakes and Tips

  • Directly assigning a reactive property on the host from the controller: as noted in section 5, a controller doesn't need to declare its own reactive properties on the host; it's enough to keep its own internal state and call this.host.requestUpdate() when that state changes. Trying to do this.host.cercaDeVencer = ... would additionally force the host to declare that property in its own static properties, coupling the controller's internal implementation to each specific host's property declarations.
  • Forgetting host.addController(this) inside the controller's own constructor: without this registration, Lit never calls hostConnected or hostDisconnected, and the controller behaves like an ordinary object with no real connection to its host's lifecycle, even though its code may look correct at a glance.
  • Making the controller depend on properties too specific to a single component: the more generic the contract a controller demands of its host (in this example, a single fechaLimite property, with configurable parameters for the rest), the easier it will be to reuse in future components that don't even exist yet; a controller that assumed, for example, the existence of a very specific method of <task-card> would lose much of its value as a reusable piece.
  • Not cleaning up resources in hostDisconnected: exactly the same risk noted in lesson 06-01 about disconnectedCallback applies here; a controller that starts a timer or a subscription in hostConnected and doesn't release it in hostDisconnected causes the same memory leak, now potentially multiplied by every host that uses it.

Exercises

  1. Add a second configuration parameter to ContadorTiempoRestanteController, onCambio, an optional callback function that the controller invokes (in addition to calling requestUpdate()) every time cercaDeVencer changes value, passing it the new value as an argument. Modify the instantiation in <task-card> to take advantage of this callback and show a console.log when the card enters or leaves the "close to due" state.
  2. Explain, based on section 3, what difference there is between implementing hostUpdate() in a controller and overriding willUpdate() directly in the component's class, regarding where the code physically lives.
  3. A teammate, upon seeing the controller from section 5, proposes that instead of this.host.requestUpdate() it would be simpler to have the host itself expose a reactive cercaDeVencer property and have the controller assign it directly. Argue, based on section 7 and the code in section 6 itself, why the current approach (the data lives in the controller, not in the host) means <task-card> would need to change less code if the controller were replaced in the future by a different implementation.

Solutions

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(), and _calcularSiCercaDeVencer() unchanged.
}
this._contadorTiempo = new ContadorTiempoRestanteController(this, {
  onCambio: (valor) => console.log(`cercaDeVencer ahora es ${valor}`),
});
  1. Implementing hostUpdate() in a controller keeps that code physically inside the controller's class, entirely separate from the component's class; overriding willUpdate() directly in the component mixes that same kind of logic (something that must run before render()) inside the TaskCard class body itself. Both run at exactly the same point in the cycle, but only the controller version allows reusing that logic in a second component without copying code: the version with willUpdate() directly in the class stays tied to that particular class.
  2. With the current design, <task-card> knows absolutely nothing about how the controller calculates cercaDeVencer internally: it only knows that this._contadorTiempo exposes a cercaDeVencer property readable in render(). If ContadorTiempoRestanteController were replaced in the future by a different implementation (for example, one that, instead of a setInterval, used some more efficient browser API for background timers), <task-card> wouldn't have to change a single line, as long as the new implementation kept exposing the same cercaDeVencer property. If, instead, the controller directly assigned this.host.cercaDeVencer, <task-card> would have to keep declaring that property in its own static properties solely to accommodate an internal implementation detail of the controller, mixing <task-card>'s public surface with the internal mechanism of a piece that, in theory, should be swappable without friction.

Conclusion

This lesson has presented reactive controllers as Lit's recommended alternative for encapsulating reusable logic with its own state and lifecycle needs: an ordinary JavaScript object, registered with addController, capable of hooking into hostConnected, hostDisconnected, hostUpdate, and hostUpdated at exactly the same points already known as methods of a component's own class. <task-card>'s timer, until now trapped inside its own class, now lives in ContadorTiempoRestanteController, ready to be reused in any other TaskFlow component that needs the same kind of proximity-to-deadline warning.

Still to be resolved, however, is when a reactive controller is the right choice and when the more classic JavaScript alternative for this same general problem is preferable: mixins. The next lesson explains the mixin pattern as applied to Lit, its most frequent limitations, and the concrete criterion for choosing between one technique and the other depending on the kind of behavior to be shared.

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