TaskFlow has already had, since module 3, a first contact with events: <task-card> toggles its internal expandida state on click, via @click="${this.alternarExpandida}" in its template. At that point the explanation was left pending so as not to distract from the underlying topic of that lesson, internal state. The time has come to return to that syntax and understand it thoroughly: what exactly the @ prefix does, how it differs from listening to events "by hand" with addEventListener, what information the event object carries, how to configure the listener with options like capture or once, and why the value of this inside a Lit event handler behaves as one would expect without needing any extra trick.
Contents
- The
@eventsyntax in Lit templates - What really happens behind
@event - The
eventobject andevent.target - Listener options with the object syntax
- The binding of
thisin handlers - Formalizing the click of
<task-card> - Closing: the next step, communicating outward
- The
@event syntax in Lit templates
@event syntax in Lit templatesInside an html template, any attribute that starts with @ is not an HTML attribute at all: it is an instruction to Lit telling it "add a listener for this event on this element, and call this function when it fires." The name that follows the @ is the DOM event name as-is, without the on prefix used by classic HTML attributes (onclick, onchange...):
render() {
return html`
<button @click="${this.gestionarClic}">Marcar como hecha</button>
<input @input="${this.gestionarInput}" />
<form @submit="${this.gestionarEnvio}"></form>
`;
}The value between quotes, after the equals sign, is an expression of function type: a reference to the method Lit must invoke when the event occurs. It is important not to confuse this with invoking the function immediately (@click="${this.gestionarClic()}", with parentheses, would almost certainly be a mistake): what gets passed to Lit is the function itself, unexecuted, so that the browser decides when to call it, exactly at the moment the event occurs.
This syntax works with any DOM event, not just the most common ones like click or input: @mouseenter, @keydown, @focus, @dragstart, or any other standard browser event name is valid after the @. It also works, as will be seen in the next lesson, with custom events created by other components, with no syntax difference at all.
- What really happens behind
@event
@event@event is not exclusive Lit magic invented from scratch: under the hood, when Lit processes a template and finds an event-type binding, it calls addEventListener(nombreDelEvento, funcion) on the corresponding DOM element, exactly the same native method one would use writing plain JavaScript without any framework involved. The real difference lies in the convenience and the integration with the rest of Lit's template system:
// With manual addEventListener, outside Lit
const boton = document.querySelector('button');
boton.addEventListener('click', gestionarClic);
// With Lit, declarative inside the template
html`<button @click="${gestionarClic}">Marcar como hecha</button>`With manual addEventListener, the element must first be located (usually with querySelector or getElementById), and that location-and-subscription code lives separately from the template that describes the element's structure, forcing the developer to mentally track the relationship between the two pieces of code. With @event, the subscription is written right next to the element it belongs to, inside the same declarative template, so reading the html is enough to know which events each element listens to.
In addition, Lit automatically manages the listener's lifecycle: when the template re-renders and the element in question is still the same DOM node (the usual case, as seen in module 2), Lit reuses the existing listener instead of adding a new one on every update; and if the element is removed from the DOM because a conditional expression stops rendering it, the browser itself frees that node and its listener without needing to call removeEventListener manually, exactly as happens with any DOM node that gets removed. This avoids one of the most common mistakes when working with events "by hand": forgetting to remove a listener and ending up with ghost handlers running on elements that should no longer be active.
- The
event object and event.target
event object and event.targetEvery event handler, whether declared with @event in Lit or registered with addEventListener outside any framework, automatically receives an argument: the Event object (or one of its subclasses, such as MouseEvent or InputEvent) that describes the event that occurred. Lit changes nothing here: the object that reaches the handler is the same standard browser object, with all its usual properties.
gestionarClic(event) {
console.log(event.type); // "click"
console.log(event.target); // el elemento del DOM que originó el evento
}event.target is, almost always, the most useful property of the event object: it points to the specific DOM element on which the event originally occurred. This is especially relevant when the listener is not placed directly on the element the user clicked, but on a container element (a technique called event delegation):
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}`);
}Thanks to the fact that DOM events propagate by default from the element where they occur toward their ancestors (a mechanism called bubbling, which will be revisited in the next lesson), a single @click on the <ul> is able to capture the click on any of its <li> elements, and event.target makes it possible to tell which one it actually happened on.
Be careful not to confuse event.target with event.currentTarget: target is always the deepest element where the event originated, while currentTarget is the element on which the listener currently running is attached (in the example above, always the <ul>, whichever <li> was clicked). Inside a handler declared with @event on a specific element, event.currentTarget usually matches that element; the distinction becomes relevant mainly in delegation scenarios like the one above.
- Listener options with the object syntax
Native addEventListener accepts, as a third argument, an options object that modifies how the listener behaves: capture (to listen during the capture phase of the event instead of the bubbling phase), once (so the listener runs a single time and is then automatically removed) and passive (to tell the browser the handler will never call preventDefault(), which lets it optimize scrolling for touch and wheel events). Lit exposes these same options without leaving the declarative syntax, using an alternative way of writing the binding's value: instead of passing the function directly, an object is passed with two kinds of properties, handleEvent (the handler function) and the rest of the options at the same level:
render() {
return html`
<button
@click="${{ handleEvent: () => this.gestionarClic(), once: true }}"
>
Confirmar (solo una vez)
</button>
`;
}This object relies on a little-known detail of the addEventListener specification: the browser accepts, as its second argument, either a plain function or any object that has a handleEvent method, and invokes it the same way in both cases. Lit takes advantage of exactly this standard capability to allow adding options without inventing any syntax of its own. The following table summarizes the three available options:
| Option | Effect |
|---|---|
capture: true |
The listener runs during the capture phase (from the document root down to the element), before the event reaches the element where it occurred and starts bubbling upward. |
once: true |
The listener runs at most once; after the first invocation, the browser removes it automatically, as if removeEventListener had been called. |
passive: true |
Declares that the handler will never call event.preventDefault(), which allows the browser to optimize certain events (especially touchstart, touchmove and wheel) without waiting for the handler to finish before deciding whether to scroll. |
In practice, within TaskFlow, these options are used sparingly: the vast majority of handlers in this course are simple functions with no need for additional options, and it is best to reserve them for the specific cases where they add something (for example, once: true on a confirmation button that should not be clickable twice by mistake, or passive: true on a scroll listener over a long list of tasks).
- The binding of
this in handlers
this in handlersA detail that surprises anyone arriving at Lit from plain JavaScript is that @click="${this.alternarExpandida}" works correctly, and inside alternarExpandida() the word this refers to the component instance, with no need for any explicit .bind(this). To understand why, it helps to recall how this works in JavaScript: in a normal class method, this depends on how the function is called, not on where it was declared. If a function were extracted from its object and called loose, this would stop pointing to the instance:
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)When Lit adds the listener with addEventListener, and the browser fires the event, the function is invoked in a context that, by default, does not preserve the original this of the class method. However, the usual pattern in this course —declaring handlers as regular class methods (alternarExpandida() { ... }) and passing them with @click="${this.alternarExpandida}"— works without issue because Lit automatically binds the this of methods declared in render() to the component instance, thanks to the fact that Lit internally wraps the reference to the function in such a way that, when invoked, it preserves the context of the instance it was read from (this.alternarExpandida remembers which this it belongs to).
This convenience is not always guaranteed in every scenario (for example, if the function is manually extracted into a loose variable before being passed to the template, as in the example above), so two explicit alternatives are worth keeping in mind, especially if an unexpected this ever turns up inside a handler:
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>
`;
}
}Option A (a class field with an arrow function) is a robust alternative widely used in practice: being an arrow function, it captures the lexical this of the instance constructor at the moment the field is created, and that this stays fixed forever, no matter how the function is later invoked. Option B, an arrow function declared directly inside the template, also correctly captures this for the same reason, but has a subtle cost: being a new function on every call to render(), Lit cannot recognize it as "the same" function between updates, forcing it to remove the previous listener and add a new one on every render. For the pattern used in this course, with regular class methods referenced as this.metodo, this is not a problem at all because Lit already resolves the this binding automatically, so options A and B remain alternatives worth knowing, not the default pattern.
- Formalizing the click of
<task-card>
<task-card>With all the preceding theory, it is now possible to read in real detail the click handler <task-card> has had since module 3, with nothing new added to its behavior, but plenty added to understanding it:
// 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>
`;
}
}Now alternarExpandida explicitly declares the event parameter, although in this particular case no specific data from it is needed beyond confirming that it arrived (the console.log line is purely illustrative, to see in the browser console what type of event occurred and which element triggered it; it would be removed in real production code). The listener sits directly on the <article>, so a click anywhere on the card —the title, the status badge, the urgency warning— triggers the handler, and event.target would be, depending on exactly where the click happens, the <h3>, the badge's <span>, or the <article> itself, thanks to the event bubbling mentioned in section 3. This is exactly why a single @click on the template's root element is enough to capture clicks across the entire card, without needing to repeat the listener on every inner element.
Common Mistakes and Tips
- Writing
@click="${this.metodo()}"with parentheses: this invokesmetodo()immediately during rendering, instead of passing it as a reference for the browser to call later; it is almost always a mistake, unlessmetodo()is deliberately designed to return another function (an uncommon pattern in this course). - Using
onclick="..."inside a Lit template: that is the classic HTML attribute syntax, evaluated by the browser in a completely different way (as a string of code executed with limited access to the class scope); in Lit, the correct declarative listener always uses the@prefix, neveron. - Extracting a method into a loose variable before passing it to the template: as explained in section 5, something like
const fn = this.alternarExpandida; html<button @click="${fn}">``` loses the correctthisunless it was declared as a class field with an arrow function; it is best to always passthis.metododirectly inside the template, or use the class field pattern. - Forgetting that listener options need the
handleEventobject: writing@click="${{ once: true }}", without thehandleEventproperty, runs no handler at all; the object syntax always requires includinghandleEventalongside whichever options are needed.
Exercises
- Add to
<task-card>a second handler,gestionarTeclado(event), bound with@keydownon the<article>, that callsthis.alternarExpandida(event)only whenevent.keyis'Enter'or' '(space bar), so the card can also be expanded with the keyboard. - Using the object syntax seen in section 4, modify the
@clickon the<article>of<task-card>so the listener usesonce: true, and explain in your own words what observable behavior would change when testing it in the browser (hint: think about how many times the card could be expanded and collapsed). - Write a small
<contador-clics>component with a<button>whose click handler receives theeventand logsevent.currentTargetandevent.targetto the console; add a<span>with a text icon inside the button, click exactly on that<span>, and explain why both values differ in that specific case but match if you click anywhere else on the button.
Solutions
gestionarTeclado(event) {
if (event.key === 'Enter' || event.key === ' ') {
this.alternarExpandida(event);
}
}
render() {
return html`
<article @click="${this.alternarExpandida}" @keydown="${this.gestionarTeclado}" tabindex="0">
...
</article>
`;
}tabindex="0" is also added because an <article> is not, by default, an element the user can move focus to with the keyboard (unlike a <button> or an <a>); without focus, the keydown event would never occur on it when pressing keys.
-
With
once: true, theclicklistener is automatically removed after it fires for the first time. In practice, this means the card could be expanded (or collapsed, depending on the initial value ofexpandida) exactly once by clicking; from then on, subsequent clicks on the card would have no effect at all, because the browser would already have removed the listener after the first execution. For the real case of<task-card>, where the user is expected to be able to expand and collapse it repeatedly,once: truewould be counterproductive; it makes sense, on the other hand, for actions that should deliberately be able to happen only once, such as confirming a form submission.
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>
`;
}
}If the click happens exactly on the <span> with the ★ icon, event.target points to that <span> (the deepest element where the click physically originated), while event.currentTarget still points to the <button>, because that is the element the @click listener is actually attached to. If, instead, the click happens on the text "Pulsa aquí" or on any area of the button outside the <span>, event.target matches the <button> itself, and therefore event.currentTarget too. The difference only appears when the physical click happens on a child element different from the one the listener is attached to.
Conclusion
This lesson has formalized something TaskFlow had already been using since module 3: Lit's @event syntax for listening to DOM events declaratively, its direct relationship to native addEventListener, the role of event.target in knowing exactly which element an event occurred on, the listener options (capture, once, passive) via the object syntax with handleEvent, and why this inside Lit handlers correctly points to the component instance without needing an explicit bind in the usual pattern of this course.
Everything seen so far, however, still happens inside a single component: <task-card> reacts to its own clicks by modifying its own internal state, but has no way of telling anyone else about it. In the next lesson, "Custom Events: Communication from Child to Parent," we will take the step that truly connects TaskFlow's components with each other: a custom event of our own, tarea-cambiada, will be created so <task-card> can notify whoever contains it that the user has changed a task's state, without the card needing to know or directly modify anything belonging to its parent component.
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
