TaskFlow ya tiene, desde el módulo 3, un primer contacto con los eventos: <task-card> alterna su estado interno expandida al hacer clic, mediante @click="${this.alternarExpandida}" en su plantilla. En aquel momento se dejó la explicación pendiente para no desviar la atención del tema de fondo de aquella lección, el estado interno. Ha llegado el momento de volver sobre esa sintaxis y entenderla a fondo: qué hace exactamente el prefijo @, en qué se diferencia de escuchar eventos "a mano" con addEventListener, qué información trae consigo el objeto event, cómo configurar el listener con opciones como capture o once, y por qué el valor de this dentro de un manejador de eventos de Lit se comporta como cabría esperar sin que haga falta ningún truco adicional.

Contenido

  1. La sintaxis @evento en las plantillas de Lit
  2. Qué ocurre realmente detrás de @evento
  3. El objeto event y event.target
  4. Opciones del listener con la sintaxis de objeto
  5. El binding de this en los manejadores
  6. Formalizando el clic de <task-card>
  7. Cierre: el siguiente paso, comunicar hacia fuera

  1. La sintaxis @evento en las plantillas de Lit

Dentro de una plantilla html, cualquier atributo que empiece por @ no es un atributo HTML en absoluto: es una instrucción para Lit que le indica "añade un listener de este evento sobre este elemento, y llama a esta función cuando se dispare". El nombre que sigue al @ es el nombre del evento del DOM tal cual, sin el prefijo on que usan los atributos HTML clásicos (onclick, onchange...):

render() {
  return html`
    <button @click="${this.gestionarClic}">Marcar como hecha</button>
    <input @input="${this.gestionarInput}" />
    <form @submit="${this.gestionarEnvio}"></form>
  `;
}

El valor entre comillas, tras el signo igual, es una expresión de tipo función: una referencia al método que Lit debe invocar cuando el evento se produzca. Es importante no confundir esto con invocar la función inmediatamente (@click="${this.gestionarClic()}", con paréntesis, sería un error casi seguro): lo que se pasa a Lit es la propia función, sin ejecutar, para que sea el navegador quien decida cuándo llamarla, exactamente en el momento en que ocurra el evento.

Esta sintaxis funciona con cualquier evento del DOM, no solo con los más habituales como click o input: @mouseenter, @keydown, @focus, @dragstart, o cualquier otro nombre de evento estándar del navegador es válido tras el @. También funciona, como se verá en la lección siguiente, con eventos personalizados creados por otros componentes, sin ninguna diferencia de sintaxis.

  1. Qué ocurre realmente detrás de @evento

@evento no es magia exclusiva de Lit inventada desde cero: por debajo, cuando Lit procesa una plantilla y encuentra un binding de tipo evento, llama a addEventListener(nombreDelEvento, funcion) sobre el elemento del DOM correspondiente, exactamente el mismo método nativo que se usaría escribiendo JavaScript sin ningún framework de por medio. La diferencia real está en la comodidad y en la integración con el resto del sistema de plantillas de Lit:

// Con addEventListener manual, fuera de Lit
const boton = document.querySelector('button');
boton.addEventListener('click', gestionarClic);

// Con Lit, declarativo dentro de la plantilla
html`<button @click="${gestionarClic}">Marcar como hecha</button>`

Con addEventListener manual, hay que localizar primero el elemento (normalmente con querySelector o getElementById), y ese código de localización y suscripción vive separado de la plantilla que describe la estructura del elemento, lo que obliga a mantener mentalmente la relación entre ambos fragmentos de código. Con @evento, la suscripción está escrita justo al lado del elemento al que pertenece, dentro de la misma plantilla declarativa, así que basta con leer el html para saber qué eventos escucha cada elemento.

Además, Lit gestiona automáticamente el ciclo de vida del listener: cuando la plantilla se vuelve a renderizar y el elemento en cuestión sigue siendo el mismo nodo del DOM (lo habitual, como se vio en el módulo 2), Lit reutiliza el listener existente en lugar de añadir uno nuevo en cada actualización; y si el elemento se elimina del DOM porque una expresión condicional deja de renderizarlo, el propio navegador libera ese nodo y su listener sin que haga falta llamar a removeEventListener manualmente, tal y como ocurre con cualquier nodo del DOM que se retira. Esto evita uno de los errores más comunes al trabajar con eventos "a mano": olvidarse de eliminar un listener y acabar con manejadores fantasma ejecutándose sobre elementos que ya no deberían estar activos.

  1. El objeto event y event.target

Todo manejador de eventos, tanto si se declara con @evento en Lit como si se registra con addEventListener fuera de cualquier framework, recibe automáticamente un argumento: el objeto Event (o una de sus subclases, como MouseEvent o InputEvent) que describe el evento que se ha producido. Lit no cambia nada en este punto: el objeto que llega al manejador es el mismo objeto estándar del navegador, con todas sus propiedades habituales.

gestionarClic(event) {
  console.log(event.type);   // "click"
  console.log(event.target); // el elemento del DOM que originó el evento
}

event.target es, casi siempre, la propiedad más útil del objeto evento: apunta al elemento concreto del DOM sobre el que ocurrió el evento originalmente. Esto es especialmente relevante cuando el listener no está puesto directamente sobre el elemento que el usuario ha pulsado, sino sobre un elemento contenedor (una técnica llamada delegación de eventos):

render() {
  return html`
    <ul @click="${this.gestionarClicEnLista}">
      <li data-id="1">Tarea 1</li>
      <li data-id="2">Tarea 2</li>
      <li data-id="3">Tarea 3</li>
    </ul>
  `;
}

gestionarClicEnLista(event) {
  const idPulsado = event.target.dataset.id;
  console.log(`Se ha pulsado la tarea ${idPulsado}`);
}

Gracias a que los eventos del DOM se propagan por defecto desde el elemento donde ocurren hacia sus ancestros (un mecanismo llamado bubbling, sobre el que se volverá en la lección siguiente), un único @click en el <ul> es capaz de capturar el clic sobre cualquiera de sus <li>, y event.target permite distinguir sobre cuál de ellos ocurrió realmente.

Conviene no confundir event.target con event.currentTarget: target es siempre el elemento más profundo donde se originó el evento, mientras que currentTarget es el elemento sobre el que está puesto el listener que se está ejecutando en ese momento (en el ejemplo anterior, siempre el <ul>, sea cual sea el <li> pulsado). Dentro de un manejador declarado con @evento sobre un elemento concreto, event.currentTarget suele coincidir con ese elemento; la distinción se vuelve relevante, sobre todo, en escenarios de delegación como el anterior.

  1. Opciones del listener con la sintaxis de objeto

addEventListener nativo admite, como tercer argumento, un objeto de opciones que modifica cómo se comporta el listener: capture (para escuchar en la fase de captura del evento en lugar de la fase de burbujeo), once (para que el listener se ejecute una única vez y se elimine automáticamente después) y passive (para indicar al navegador que el manejador nunca va a llamar a preventDefault(), lo que le permite optimizar el scroll en eventos táctiles y de rueda). Lit expone estas mismas opciones sin salir de la sintaxis declarativa, usando una forma alternativa de escribir el valor del binding: en lugar de pasar directamente la función, se pasa un objeto con dos propiedades, handleEvent (la función manejadora) y el resto de opciones al mismo nivel:

render() {
  return html`
    <button
      @click="${{ handleEvent: () => this.gestionarClic(), once: true }}"
    >
      Confirmar (solo una vez)
    </button>
  `;
}

Este objeto se apoya en un detalle poco conocido de la especificación de addEventListener: el navegador acepta, como segundo argumento, tanto una función directamente como cualquier objeto que tenga un método handleEvent, y lo invoca de la misma forma en ambos casos. Lit aprovecha exactamente esta capacidad del estándar para permitir añadir opciones sin inventar ninguna sintaxis propia. La tabla siguiente resume las tres opciones disponibles:

Opción Efecto
capture: true El listener se ejecuta en la fase de captura (de la raíz del documento hacia el elemento), antes de que el evento llegue al elemento donde ocurrió y empiece a burbujear hacia arriba.
once: true El listener se ejecuta como máximo una vez; después de la primera invocación, el navegador lo elimina automáticamente, como si se hubiera llamado a removeEventListener.
passive: true Declara que el manejador nunca llamará a event.preventDefault(), lo que permite al navegador optimizar ciertos eventos (especialmente touchstart, touchmove y wheel) sin esperar a que el manejador termine para decidir si hace scroll.

En la práctica, dentro de TaskFlow, estas opciones se usan con moderación: la inmensa mayoría de los manejadores de este curso son simples funciones sin necesidad de opciones adicionales, y conviene reservarlas para los casos concretos en los que aportan algo (por ejemplo, once: true en un botón de confirmación que no debería poder pulsarse dos veces por error, o passive: true en un listener de scroll sobre una lista larga de tareas).

  1. El binding de this en los manejadores

Un detalle que sorprende a quien llega a Lit desde JavaScript "a pelo" es que @click="${this.alternarExpandida}" funciona correctamente, y dentro de alternarExpandida() la palabra this se refiere a la instancia del componente, sin necesidad de ningún .bind(this) explícito. Para entender por qué, hace falta recordar cómo funciona this en JavaScript: en un método normal de clase, this depende de cómo se llama la función, no de dónde se declaró. Si se extrajera una función de su objeto y se llamara suelta, this dejaría de apuntar a la instancia:

class TaskCard extends LitElement {
  alternarExpandida() {
    this.expandida = !this.expandida; // "this" depende de cómo se invoque este método
  }
}

const metodo = tarjeta.alternarExpandida;
metodo(); // ERROR: this ya no es la instancia de TaskCard, es undefined (en modo estricto)

Cuando Lit añade el listener con addEventListener, y el navegador dispara el evento, la función se invoca en un contexto que, por defecto, no preserva el this original del método de clase. Sin embargo, el patrón habitual de este curso —declarar los manejadores como métodos normales de la clase (alternarExpandida() { ... }) y pasarlos con @click="${this.alternarExpandida}"— funciona sin problemas porque Lit vincula automáticamente el this de los métodos declarados en render() a la instancia del componente, gracias a que internamente Lit envuelve la referencia a la función de forma que, al invocarla, preserva el contexto de la instancia desde la que se leyó (this.alternarExpandida recuerda a qué this pertenece).

Esta comodidad no siempre está garantizada en todos los escenarios (por ejemplo, si se extrae la función manualmente en una variable suelta antes de pasarla a la plantilla, como en el ejemplo anterior), así que dos alternativas explícitas conviene tenerlas presentes, especialmente si en algún momento aparece un this inesperado dentro de un manejador:

class TaskCard extends LitElement {
  // Opción A: class field con arrow function.
  // Las arrow functions no tienen su propio "this": lo capturan
  // del contexto donde se definen, que aquí es la propia instancia.
  alternarExpandida = () => {
    this.expandida = !this.expandida;
  };

  // Opción B: arrow function en línea, directamente en la plantilla.
  render() {
    return html`
      <article @click="${() => { this.expandida = !this.expandida; }}">
        ...
      </article>
    `;
  }
}

La opción A (class field con arrow function) es una alternativa robusta y muy usada en la práctica: al ser una arrow function, captura el this léxico del constructor de la instancia en el momento en que se crea el campo, y ese this queda fijado para siempre, sea cual sea la forma en que después se invoque la función. La opción B, una arrow function declarada directamente dentro de la plantilla, también captura correctamente this por el mismo motivo, pero tiene un coste sutil: al ser una función nueva en cada llamada a render(), Lit no puede reconocerla como "la misma" función entre actualizaciones, lo que le obliga a quitar el listener anterior y poner uno nuevo en cada renderizado. Para el patrón de este curso, con métodos normales de clase referenciados como this.metodo, esto no supone ningún problema porque Lit ya resuelve el binding de this automáticamente, así que las opciones A y B quedan como alternativas a conocer, no como el patrón por defecto.

  1. Formalizando el clic de <task-card>

Con toda la teoría anterior, se puede ahora leer con detalle real el manejador de clic que <task-card> ya tenía desde el módulo 3, sin nada nuevo que añadir al comportamiento, pero sí a la comprensión:

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

  constructor() {
    super();
    this.titulo = 'Tarea sin título';
    this.estado = 'pendiente';
    this.prioridad = 3;
    this.urgente = false;
    this.expandida = false;
    this.fechaLimite = null;
  }

  alternarExpandida(event) {
    console.log('Evento recibido:', event.type, 'sobre', event.target.tagName);
    this.expandida = !this.expandida;
  }

  render() {
    return html`
      <article @click="${this.alternarExpandida}">
        <h3>${this.titulo}</h3>
        ${this.renderInsigniaEstado()}
        <p>Prioridad: ${this.prioridad}</p>
        ${this.urgente && html`<p class="aviso">⚠ Urgente</p>`}
        ${this.expandida
          ? html`<div class="detalle"><p>Estado interno: la tarjeta está expandida.</p></div>`
          : ''}
      </article>
    `;
  }
}

Ahora alternarExpandida declara explícitamente el parámetro event, aunque en este caso concreto no se necesita ningún dato específico de él más allá de constatar que ha llegado (la línea de console.log es solo ilustrativa, para ver en la consola del navegador qué tipo de evento y qué elemento lo originó; en el código real de producción se retiraría). El listener está puesto directamente sobre el <article>, así que un clic en cualquier parte de la tarjeta —el título, la insignia de estado, el aviso de urgencia— dispara el manejador, y event.target sería, según dónde se haga clic exactamente, el <h3>, el <span> de la insignia o el propio <article>, gracias al burbujeo de eventos mencionado en el apartado 3. Este es exactamente el motivo por el que basta un único @click en el elemento raíz de la plantilla para capturar clics sobre toda la tarjeta, sin necesidad de repetir el listener en cada elemento interno.

Errores Comunes y Consejos

  • Escribir @click="${this.metodo()}" con paréntesis: esto invoca metodo() inmediatamente durante el renderizado, en lugar de pasarlo como referencia para que el navegador lo llame más tarde; casi siempre es un error, salvo que metodo() esté diseñado deliberadamente para devolver otra función (un patrón infrecuente en este curso).
  • Usar onclick="..." dentro de una plantilla de Lit: esa es la sintaxis de atributo HTML clásico, evaluada por el navegador de forma totalmente distinta (como una cadena de código que se ejecuta con acceso limitado al ámbito de la clase); en Lit, el listener declarativo correcto siempre usa el prefijo @, nunca on.
  • Extraer un método a una variable suelta antes de pasarlo a la plantilla: como se explicó en el apartado 5, algo como const fn = this.alternarExpandida; html<button @click="${fn}">``` pierde el this correcto salvo que se haya declarado como class field con arrow function; conviene pasar siempre this.metodo directamente dentro de la plantilla, o usar el patrón de class field.
  • Olvidar que las opciones del listener necesitan el objeto handleEvent: escribir @click="${{ once: true }}", sin la propiedad handleEvent, no ejecuta ningún manejador; la sintaxis de objeto exige siempre incluir handleEvent junto a las opciones que se quieran usar.

Ejercicios

  1. Añade a <task-card> un segundo manejador, gestionarTeclado(event), enlazado con @keydown sobre el <article>, que llame a this.alternarExpandida(event) únicamente cuando event.key sea 'Enter' o ' ' (barra espaciadora), para que la tarjeta también se pueda expandir con el teclado.
  2. Usando la sintaxis de objeto vista en el apartado 4, modifica el @click del <article> de <task-card> para que el listener use once: true, y explica con tus propias palabras qué comportamiento observable cambiaría al probarlo en el navegador (pista: piensa en cuántas veces se podría expandir y contraer la tarjeta).
  3. Escribe un pequeño componente <contador-clics> con un <button> cuyo manejador de clic reciba el event y muestre por consola event.currentTarget y event.target; añade dentro del botón un <span> con un icono de texto, haz clic exactamente sobre ese <span>, y explica por qué ambos valores difieren en ese caso concreto pero coinciden si se hace clic en cualquier otra parte del botón.

Soluciones

gestionarTeclado(event) {
  if (event.key === 'Enter' || event.key === ' ') {
    this.alternarExpandida(event);
  }
}

render() {
  return html`
    <article @click="${this.alternarExpandida}" @keydown="${this.gestionarTeclado}" tabindex="0">
      ...
    </article>
  `;
}

Se añade también tabindex="0" porque un <article> no es, por defecto, un elemento al que el usuario pueda llevar el foco con el teclado (a diferencia de un <button> o un <a>); sin foco, el evento keydown nunca llegaría a producirse sobre él al pulsar teclas.

  1. Con once: true, el listener de click se elimina automáticamente después de la primera vez que se dispara. En la práctica, esto significa que la tarjeta se podría expandir (o contraer, según el valor inicial de expandida) una única vez con el clic; a partir de ahí, sucesivos clics sobre la tarjeta no tendrían ningún efecto, porque el navegador ya habría retirado el listener tras la primera ejecución. Para el caso real de <task-card>, donde se espera poder expandir y contraer repetidamente, once: true sería contraproducente; tiene sentido, en cambio, en acciones que deliberadamente solo deben poder ocurrir una vez, como confirmar el envío de un formulario.

class ContadorClics extends LitElement {
  gestionarClic(event) {
    console.log('target:', event.target);
    console.log('currentTarget:', event.currentTarget);
  }

  render() {
    return html`
      <button @click="${this.gestionarClic}">
        <span>★</span> Pulsa aquí
      </button>
    `;
  }
}

Si el clic ocurre exactamente sobre el <span> con el icono , event.target apunta a ese <span> (el elemento más profundo donde se originó físicamente el clic), mientras que event.currentTarget sigue apuntando al <button>, porque es sobre ese elemento donde está realmente puesto el listener con @click. Si en cambio el clic ocurre sobre el texto "Pulsa aquí" o sobre cualquier zona del botón fuera del <span>, event.target coincide con el propio <button>, y por tanto con event.currentTarget. La diferencia solo aparece cuando el clic físico ocurre sobre un elemento hijo distinto de aquel sobre el que está puesto el listener.

Conclusión

En esta lección se ha formalizado algo que TaskFlow ya usaba desde el módulo 3: la sintaxis @evento de Lit para escuchar eventos del DOM de forma declarativa, su relación directa con addEventListener nativo, el papel de event.target para saber sobre qué elemento concreto ocurrió un evento, las opciones de listener (capture, once, passive) mediante la sintaxis de objeto con handleEvent, y por qué el this dentro de los manejadores de Lit apunta correctamente a la instancia del componente sin necesidad de bind explícito en el patrón habitual de este curso.

Todo lo visto hasta aquí, sin embargo, sigue ocurriendo dentro de un único componente: <task-card> reacciona a sus propios clics modificando su propio estado interno, pero no tiene ninguna forma de contárselo a nadie más. En la siguiente lección, "Eventos Personalizados: Comunicación de Hijo a Padre", se dará el salto que de verdad conecta los componentes de TaskFlow entre sí: se creará un evento personalizado propio, tarea-cambiada, para que <task-card> pueda avisar a quien la contenga de que el usuario ha cambiado el estado de una tarea, sin que la tarjeta necesite conocer ni modificar directamente nada de su componente padre.

Curso de Lit

Módulo 1: Introducción a Lit y Web Components

Módulo 2: Plantillas Reactivas y Renderizado

Módulo 3: Propiedades y Estado Reactivo

Módulo 4: Estilos en Componentes Lit

Módulo 5: Eventos y Comunicación entre Componentes

Módulo 6: Ciclo de Vida y Comportamiento Avanzado

Módulo 7: Directivas y Funcionalidades Avanzadas de Plantillas

Módulo 8: Integración, Interoperabilidad y Despliegue

Módulo 9: Pruebas y Buenas Prácticas

Módulo 10: Proyecto: Construyendo TaskFlow

© Copyright 2026. Todos los derechos reservados