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
- The problem: logic with its own lifecycle, trapped in a class
- What a
ReactiveControlleris - A controller's interface:
hostConnected,hostDisconnected,hostUpdate,hostUpdated addController: registering a controller on its host- Extracting
<task-card>'s timer into a reusable controller - Using the controller from
<task-card> - Why a controller and not just a helper function
- Wrap-up: controllers versus mixins
- 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.
- What a
ReactiveController is
ReactiveController isA 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.
- A controller's interface:
hostConnected, hostDisconnected, hostUpdate, hostUpdated
hostConnected, hostDisconnected, hostUpdate, hostUpdatedA 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.
addController: registering a controller on its host
addController: registering a controller on its hostFor 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.
- Extracting
<task-card>'s timer into a reusable controller
<task-card>'s timer into a reusable controllerWith 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
hostreceived as a parameter, and callshost.addController(this)itself: whoever uses this controller doesn't need to remember to calladdControllerseparately, it's enough to instantiate it passingthis(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.fechaLimitedirectly, 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 callsthis.host.requestUpdate()to ask Lit to runrender()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.
- Using the controller from
<task-card>
<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.
- 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.
- 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 dothis.host.cercaDeVencer = ...would additionally force the host to declare that property in its ownstatic 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 callshostConnectedorhostDisconnected, 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
fechaLimiteproperty, 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 aboutdisconnectedCallbackapplies here; a controller that starts a timer or a subscription inhostConnectedand doesn't release it inhostDisconnectedcauses the same memory leak, now potentially multiplied by every host that uses it.
Exercises
- Add a second configuration parameter to
ContadorTiempoRestanteController,onCambio, an optional callback function that the controller invokes (in addition to callingrequestUpdate()) every timecercaDeVencerchanges value, passing it the new value as an argument. Modify the instantiation in<task-card>to take advantage of this callback and show aconsole.logwhen the card enters or leaves the "close to due" state. - Explain, based on section 3, what difference there is between implementing
hostUpdate()in a controller and overridingwillUpdate()directly in the component's class, regarding where the code physically lives. - 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 reactivecercaDeVencerproperty 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}`),
});- Implementing
hostUpdate()in a controller keeps that code physically inside the controller's class, entirely separate from the component's class; overridingwillUpdate()directly in the component mixes that same kind of logic (something that must run beforerender()) inside theTaskCardclass 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 withwillUpdate()directly in the class stays tied to that particular class. - With the current design,
<task-card>knows absolutely nothing about how the controller calculatescercaDeVencerinternally: it only knows thatthis._contadorTiempoexposes acercaDeVencerproperty readable inrender(). IfContadorTiempoRestanteControllerwere replaced in the future by a different implementation (for example, one that, instead of asetInterval, 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 samecercaDeVencerproperty. If, instead, the controller directly assignedthis.host.cercaDeVencer,<task-card>would have to keep declaring that property in its ownstatic propertiessolely 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
- 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
