classMap, styleMap and ifDefined, covered in the previous lesson, are directives written by the Lit team itself to solve very general-purpose problems. But the mechanism that makes them possible isn't exclusive to the Lit team: it's publicly available, and any project can write its own directives when the rendering logic it needs to repeat across several templates doesn't fit well into a simple helper function. This lesson explains when it makes sense to take that step, how a custom directive is built with the directive() function and the Directive base class, and applies it to a real TaskFlow case: a resaltarSiUrgente directive that none of the tools already seen in the course can solve equally well.

Contents

  1. What a directive can do that a helper function can't
  2. The directive() function and the Directive base class
  3. The render() / update() cycle
  4. Part: access to the real DOM part
  5. Building resaltarSiUrgente
  6. Using the directive in <task-card>
  7. Cleanup with disconnected()
  8. noChange: avoiding unnecessary work

  1. What a directive can do that a helper function can't

This course has used, since module 2, helper functions like renderInsigniaEstado() to encapsulate reusable rendering logic within the same component. An ordinary helper function is enough as long as its job is limited to deciding which template to return, based on the data it receives as an argument. It's an excellent solution, and in fact it remains the default first option for the vast majority of TaskFlow's rendering logic.

There is, however, one kind of need that a helper function can't cover on its own: when the logic needs to keep its own state between one render and the next, or needs direct access to the actual DOM node occupying that position in the template, beyond the value shown on screen. A helper function runs from scratch on every call, with no memory of the previous call, and receives no reference to the DOM; it simply returns a value and finishes. When the logic needs to remember something between renders (for example, "was this task already urgent on the previous render, or has it just now entered that state?") or needs to touch the DOM directly (for example, adding a temporary class and scheduling its removal with a setTimeout), a piece with its own lifecycle is needed: a custom directive.

  1. The directive() function and the Directive base class

A custom directive is built by combining two pieces from Lit itself, both imported from lit/directive.js:

import { directive, Directive } from 'lit/directive.js';

class MiDirectiva extends Directive {
  render(...args) {
    // Logic that decides what is shown, similar to a regular helper function
    return 'algo';
  }
}

export const miDirectiva = directive(MiDirectiva);

Directive is the base class that any custom directive must extend: it defines the minimal contract (the render method) and the rest of the internal machinery that connects the directive with Lit's template engine. directive() is a factory function that takes that class and returns the function that is actually used inside templates (miDirectiva(...), in the example); each call to that function returns a new directive object, ready to be inserted into a position in an html template, the same way classMap(...) or until(...) have been used elsewhere in this module.

An important detail that sets directives apart from ordinary helper functions: Lit creates and reuses a single instance of the Directive class for each fixed position in the template where it's used, not a new instance on every render. If resaltarSiUrgente(...) is used inside render() of <task-card>, each instance of <task-card> has its own instance of ResaltarSiUrgenteDirective, and that same instance persists, with its own internal state, for as long as that instance of <task-card> keeps existing and rendering at that exact position in its template. This persistence is precisely what makes it possible to remember something between one render and the next, something impossible with an ordinary helper function.

  1. The render() / update() cycle

A directive can implement two methods, both optional except render, which Lit invokes on every render of the position where the directive is placed:

Method When it runs What it's for
render(...args) Always, unless update() decides to skip it Computing what value should be shown, just like a helper function would
update(part, args) Before render(), with direct access to the DOM part Reading or modifying the DOM directly, deciding whether render() needs to run again

If a directive doesn't need to touch the DOM directly or compare it with the previous render, it's enough to implement render(), and ignore update() entirely (Lit will call update() with its default implementation, which simply delegates to render()). update() is only needed when the logic requires the table's second element: access to the real DOM part before deciding what to show, as will be seen in section 5 with resaltarSiUrgente.

  1. Part: access to the real DOM part

When update(part, args) is implemented, the first parameter (part) is an object representing the specific position in the template where the directive is placed, with a different shape depending on which type of position the directive is used in:

  • A ChildPart, if the directive occupies the position of a child node (html\

    ${miDirectiva()}

    ``).
  • An AttributePart, if it occupies an attribute's value (html\
    ``).
  • An ElementPart, if the directive is placed directly on the element's tag, without being associated with any specific attribute (html\<div ${miDirectiva()}>
``).

All three types of Part expose an element property, which gives direct access to the affected DOM node: the element itself in the case of an ElementPart, or the element the attribute or child node belongs to in the other two cases. This direct reference to the DOM is exactly what no helper function can offer on its own, and it's the piece that makes the example in the next section possible.

  1. Building resaltarSiUrgente

TaskFlow needs a specific visual effect that neither classMap nor any technique already seen in the course solves well: when a task enters the "about to expire" state (the cercaDeVencer computed by ContadorTiempoRestanteController, from lesson 06-03), its card should briefly highlight with a second-and-a-half animation, to draw attention, and then return to its normal appearance without the user needing to do anything. classMap could apply a class for as long as cercaDeVencer is true, but that would keep the class active for the entire duration of that state, not just at the moment of transition; what's needed is detecting the change from false to true, and reacting with a temporary effect that removes itself, using a setTimeout.

// src/directives/resaltar-si-urgente.js
import { directive, Directive } from 'lit/directive.js';
import { nothing } from 'lit';

class ResaltarSiUrgenteDirective extends Directive {
  constructor(partInfo) {
    super(partInfo);
    this._yaResaltada = false;
    this._idTimeout = null;
  }

  update(part, [urgente]) {
    const elemento = part.element;

    if (urgente && !this._yaResaltada) {
      elemento.classList.add('resaltada');
      this._idTimeout = setTimeout(() => {
        elemento.classList.remove('resaltada');
      }, 1500);
      this._yaResaltada = true;
    }

    if (!urgente) {
      this._yaResaltada = false;
    }

    return this.render(urgente);
  }

  render(urgente) {
    return nothing;
  }

  disconnected() {
    clearTimeout(this._idTimeout);
  }
}

export const resaltarSiUrgente = directive(ResaltarSiUrgenteDirective);

Several details deserve explanation one by one:

  • The constructor(partInfo) initializes two fields belonging to each instance of the directive: _yaResaltada, which remembers whether the card is already in the middle of the highlight effect (so as not to restart it on every render while urgente remains true), and _idTimeout, the identifier returned by setTimeout, needed to be able to cancel it if necessary. partInfo is received and forwarded to super() without being used directly in this example; it contains metadata about the template position where the directive was first instantiated.
  • update(part, [urgente]) receives as its second argument an array with the exact arguments resaltarSiUrgente(...) was called with in the template; since only one argument is passed here (urgente), it's destructured directly in the method's signature.
  • part.element gives access to the real DOM element the directive is placed on (as will be seen in section 6, <task-card>'s own <article>), and classList.add/classList.remove are called on it normally, exactly as would be done with plain DOM JavaScript with no framework involved.
  • The condition urgente && !this._yaResaltada is the one that detects the transition from "not urgent" to "urgent", not simply the current state: only the first time urgente is truthy after having been falsy (or after initialization) does this branch run, avoiding restarting the setTimeout on every subsequent render while the task remains urgent.
  • render() simply returns nothing (imported from lit, already used in lesson 02-03 for the "render nothing" case with the && operator), because this directive doesn't need to display any value at the template position; its effect is purely imperative, on the DOM, through update().

  1. Using the directive in <task-card>

With the directive already written, it's applied to <task-card> as an ElementPart, placed directly on the <article> tag without being associated with any specific attribute:

// src/components/task-card.js
import { resaltarSiUrgente } from '../directives/resaltar-si-urgente.js';

render() {
  return html`
    <article ${resaltarSiUrgente(this._contadorTiempo.cercaDeVencer)} @click="${this.alternarExpandida}">
      <h3>${this.titulo}</h3>
      ${this.renderInsigniaEstado()}
      ${this._contadorTiempo.cercaDeVencer ? html`<p class="aviso">⏰ Está a punto de vencer</p>` : ''}
    </article>
  `;
}

The ${resaltarSiUrgente(...)} syntax placed directly between the tag's name and its attributes, with no attribute name or = preceding it, is exactly what turns this directive into an ElementPart: it isn't associated with class, style, or any other specific attribute, but with the <article> element itself as a whole. On each render of this <task-card> instance, Lit calls update() on the same instance of ResaltarSiUrgenteDirective (remembered since the first render, as explained in section 2), passing it the current value of this._contadorTiempo.cercaDeVencer; the directive compares that value against its own memory of the previous transition and decides, on its own, whether it's time to add the resaltada class and schedule its removal.

  1. Cleanup with disconnected()

Besides render() and update(), the Directive class offers two additional methods, disconnected() and reconnected(), which Lit invokes when the DOM part associated with the directive disconnects or reconnects from the document (for example, if <task-card> is removed from the DOM, or if it's part of a list managed with repeat that decides to reuse or discard nodes, as seen in lesson 02-04). resaltarSiUrgente implements disconnected() to cancel any pending setTimeout with clearTimeout(this._idTimeout), for exactly the same reason, already known from disconnectedCallback in lesson 06-01 and from hostDisconnected in lesson 06-03, why any active timer should be cleaned up when the piece that scheduled it is no longer in use: without this cleanup, if a card is removed from the DOM right when _idTimeout is pending, the setTimeout would still fire 1500 ms later regardless, trying to modify the classList of an element that's no longer part of the page — a waste of work that, in more complex cases with cross-references, could also contribute to a memory leak.

  1. noChange: avoiding unnecessary work

Lit offers, also importable from lit, a special value called noChange, which a directive can return from update() (or from render()) to tell Lit "nothing has changed at this position, there's no need to touch the DOM at all". It's different from returning nothing (used in section 5 for "don't show any real value", but which does update the DOM to reflect that absence if something was there before); noChange literally means "leave the DOM exactly as it is, with no comparison or update whatsoever".

import { noChange } from 'lit';

update(part, [urgente]) {
  if (urgente === this._ultimoValorVisto) {
    return noChange;
  }
  this._ultimoValorVisto = urgente;
  // ... rest of the logic ...
  return this.render(urgente);
}

In the resaltarSiUrgente example, this optimization doesn't add anything meaningful because render() already always returns nothing (a trivial operation), so it has been left out to avoid complicating the main example; but it's worth knowing about noChange for directives whose render() does produce a value that's expensive to compute or apply to the DOM, where avoiding repeated work when the input value hasn't changed can make a real difference in performance.

Common Mistakes and Tips

  • Exporting the class directly instead of the result of directive(): export const resaltarSiUrgente = directive(ResaltarSiUrgenteDirective) is essential; exporting ResaltarSiUrgenteDirective on its own and using it as new ResaltarSiUrgenteDirective() inside a template wouldn't work, because Lit's template engine recognizes directives by the special shape directive() gives them, not by inheritance from Directive itself.
  • Forgetting disconnected() when the directive schedules timers or subscriptions: exactly the same risk pointed out in section 7 and already seen twice in module 6; any resource the directive starts in update() (an interval, a pending promise with an associated action, a listener manually added on part.element) should be cleaned up in disconnected().
  • Trying to modify the DOM directly from render() instead of update(): render() is meant to return a value that Lit inserts on its own at the corresponding position, not to perform imperative DOM manipulation; access to part.element is only available inside update() (or disconnected()/reconnected()), never as an argument to render().
  • Creating a custom directive for a case that classMap, styleMap, or a helper function already solve: as explained in section 1, the deciding criterion is specific: a custom directive is only needed when the logic must remember state between renders or touch the DOM directly; if simply deciding which template or which object to return based on current data is enough, a helper function or a built-in directive is always the simpler choice.

Exercises

  1. Add a configuration parameter to resaltarSiUrgente, so it's used as resaltarSiUrgente(this._contadorTiempo.cercaDeVencer, { duracionMs: 3000, clase: 'resaltada-larga' }), replacing the fixed values 1500 and 'resaltada' from section 5 with the received values (with duracionMs: 1500 and clase: 'resaltada' as defaults if not specified).
  2. Explain, based on section 2, what would happen if resaltarSiUrgente were used inside a list rendered with repeat (lesson 02-04) and a specific task changed position in the array: is the instance of ResaltarSiUrgenteDirective associated with that task preserved, or is a new one created? Base your answer on the fact that repeat, thanks to its key (key), reuses the same DOM node for the same logical element even if it changes position.
  3. A teammate proposes replacing resaltarSiUrgente with an ordinary helper function, resaltarSiUrgente(urgente), that returns the class 'resaltada' or '' based on the value of urgente, combined with classMap. Explain why that alternative doesn't reproduce the directive's real behavior (the temporary 1500 ms highlight that removes itself), even though at first glance it might seem like a reasonable simplification.

Solutions

class ResaltarSiUrgenteDirective extends Directive {
  constructor(partInfo) {
    super(partInfo);
    this._yaResaltada = false;
    this._idTimeout = null;
  }

  update(part, [urgente, opciones = {}]) {
    const { duracionMs = 1500, clase = 'resaltada' } = opciones;
    const elemento = part.element;

    if (urgente && !this._yaResaltada) {
      elemento.classList.add(clase);
      this._idTimeout = setTimeout(() => elemento.classList.remove(clase), duracionMs);
      this._yaResaltada = true;
    }

    if (!urgente) {
      this._yaResaltada = false;
    }

    return this.render(urgente);
  }

  render() {
    return nothing;
  }

  disconnected() {
    clearTimeout(this._idTimeout);
  }
}

export const resaltarSiUrgente = directive(ResaltarSiUrgenteDirective);
  1. As explained in section 2, Lit associates an instance of the directive with each fixed position in the template, and repeat (unlike Array.map) identifies each element by its logical key (tarea.id), not by its position in the array; as a result, if a task changes position within the list, repeat moves the same existing DOM node to the new position instead of destroying it and creating a new one, and that DOM node continuity carries over to the directive placed on it as well: the same instance of ResaltarSiUrgenteDirective, with its _yaResaltada and its _idTimeout intact, remains associated with that same task even if its visual position in the list changes. If Array.map were used instead for that same list, a reorder could make Lit reuse the node at position N to now represent a different logical task, and along with it the directive's instance, which could mistakenly carry over the state (_yaResaltada) of the task that previously occupied that position.
  2. The 'resaltada' class combined with classMap, on its own, would apply the class for the entire time urgente is true, not just during the first second and a half after the transition: the moment urgente goes back to false on a later render (for example, because the deadline is updated), the class would disappear, but for as long as urgente remains continuously true, the class would stay active indefinitely, without the "temporary flash that removes itself" effect that setTimeout inside the directive does achieve. Reproducing the real behavior would require, at minimum, that the helper function remember whether it had already shown the highlight before (exactly the _yaResaltada state of the directive) and schedule its own setTimeout to remove it, which is no longer possible with an ordinary helper function, with no state of its own between calls, as explained in section 1.

Conclusion

This lesson has introduced custom directives as Lit's tool for rendering logic that needs something more than deciding which template to return: its own state persisting between renders, and direct access to the affected DOM node through part.element. resaltarSiUrgente, built with directive() and the Directive base class, has solved a visual effect —the temporary flash when entering the urgent state— that none of the tools already seen in the course, including the previous lesson's built-in directives, could cover equally well, and it's reusable in any other TaskFlow template that needs the same warning.

With directives now mastered in both their built-in and custom forms, one last rendering problem remains to be solved in this module, different from everything seen so far: what to do when the value that needs to be shown doesn't exist yet, because it depends on an asynchronous operation in progress, such as loading TaskFlow's tasks from an external data source. The next lesson introduces the until directive for exactly that case.

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