<task-card> has known, since module 3, how to react to a click by modifying its own internal state. But an honest look at TaskFlow makes clear that this is not enough: if the user wants to mark a task as "done" from its card, that change somehow has to reach <task-list>, which is the one that keeps the actual tasks array. This lesson solves that problem with the standard Web Components mechanism for a child to communicate with whoever contains it: custom events, built with CustomEvent, dispatched with dispatchEvent, and designed to cleanly cross the Shadow DOM boundary. By the end of the lesson, <task-card> will have a small state selector and will emit a tarea-cambiada event every time the user uses it.
Contents
- Why a child should not touch its parent's state directly
CustomEvent: an event with its own databubblesandcomposed: how an event travels through the Shadow DOM- Dispatching the event with
dispatchEvent - Naming convention for custom events
- Adding a state selector to
<task-card> - Emitting
tarea-cambiada - Closing: who listens to this event
- Why a child should not touch its parent's state directly
Imagine, for a moment, a tempting but mistaken solution to the problem raised in the introduction: <task-card> receiving, as a property, a direct reference to <task-list>'s tareas array (or even a reference to the <task-list> component itself), and, when the state of a task changes, <task-card> modifying that array directly, finding the element that corresponds to it and mutating it in place.
This approach, although it may seem to save code, breaks one of the most important ideas in the design of well-encapsulated components: a child component should not know, let alone modify, its parent's internal data structure. If <task-card> had to know it lives inside an array managed by <task-list>, it would stop being a component that can be reused in isolation: <task-card> could not be used on its own anywhere else in the application (a single-task detail view, for example) without dragging along that dependency on a data structure that, in that other context, would not even exist.
The correct alternative, and the one followed by the Web Components standard since its inception, is exactly the opposite in terms of the direction of responsibility: the child does not change anything on its own in the parent; it simply announces that something has happened, without knowing or needing to know who is listening or what they will do with that information. It is the exclusive responsibility of whoever listens (usually the parent, though not necessarily) to decide what to do with the notice: update its own state, ignore it, or forward it further upward. This pattern —child announces, parent decides— is exactly what custom events exist for.
CustomEvent: an event with its own data
CustomEvent: an event with its own dataThe browser allows creating custom, non-native events via the CustomEvent constructor, which is a subclass of the standard Event class (the same family as the click and keyboard events already seen in the previous lesson). Its main difference from a native event is that any custom data can be attached through the detail property:
The constructor's first argument is the event name (a freely chosen string, whose convention will be discussed in section 5); the second is an options object where detail can contain any JavaScript value: an object, an array, a number, even undefined if the event doesn't need to carry any additional data beyond the fact that it occurred. Whoever listens to this event will access that information by reading event.detail, exactly the same way any other property of the event object is read.
bubbles and composed: how an event travels through the Shadow DOM
bubbles and composed: how an event travels through the Shadow DOMCreating a CustomEvent is not enough by itself for it to reach where it's needed: by default, an event does not bubble (it does not propagate to ancestor elements) and, if the element dispatching it lives inside a shadow root, the event does not leave that Shadow DOM boundary. Both behaviors are explicitly enabled with two constructor options:
const evento = new CustomEvent('tarea-cambiada', {
detail: { id: 3, nuevoEstado: 'hecha' },
bubbles: true,
composed: true,
});bubbles: truemakes the event, after firing on the origin element, propagate upward through all its ancestors in the DOM tree, exactly the same bubbling mechanism mentioned in the previous lesson regardingevent.target. Without this option, the event could only be heard by whoever has a listener placed directly on the element that dispatches it, which is almost useless in practice: nobody outside the component itself has, or should have, a direct reference to the specific internal element that originated the event.composed: trueis the Web Components-specific option, and the one that tends to raise the most questions: it allows the event to cross the Shadow DOM boundary, that is, to leave the shadow root where it originated toward the light DOM outside. Recall that, as explained in module 4, the Shadow DOM deliberately encapsulates what happens inside a component; that same encapsulation, withoutcomposed: true, would stop the event's bubbling right at the shadow root boundary, and would prevent<task-list>, which lives outside<task-card>'s shadow root, from ever finding out anything.
The combination of both options is, in practice, the one used almost universally for custom events that a component dispatches with the intent that its parent (or any ancestor) will listen to it: without bubbles: true the event does not go up; without composed: true, even if it goes up, it stays trapped inside the shadow root of the component that originates it. Forgetting either of the two is, by far, the most frequent mistake when working with custom events in Web Components, and is detailed in this lesson's common-mistakes section.
- Dispatching the event with
dispatchEvent
dispatchEventA CustomEvent, once created, does not happen on its own: it must be explicitly dispatched on a DOM element, via the dispatchEvent method, inherited by every element (including any class extending LitElement, which in turn extends HTMLElement) directly from the web platform:
class TaskCard extends LitElement {
notificarCambioDeEstado(nuevoEstado) {
this.dispatchEvent(
new CustomEvent('tarea-cambiada', {
detail: { nuevoEstado },
bubbles: true,
composed: true,
})
);
}
}this.dispatchEvent(...), called on the component instance itself, makes the event originate exactly on <task-card> (the custom element itself, not an internal node of its shadow root), and from there, thanks to bubbles: true and composed: true, it propagates upward through the light DOM outside: first toward its immediate parent element (usually <task-list>'s <div class="lista">), then toward <task-list> itself, and so on toward any ancestor that has a listener set for tarea-cambiada.
Note that dispatchEvent is a synchronous call: every listener currently listening for that event runs immediately, before dispatchEvent finishes executing and the code continues on the next line. This usually has no practical implications in the typical usage of this course, but it's worth knowing if one ever needs to reason about the exact order in which different pieces of code run.
- Naming convention for custom events
The name chosen for a custom event is a free-form string, but the Web Components community almost universally follows a simple convention worth respecting:
- Lowercase and hyphenated (
kebab-case), just like the names of custom elements themselves:tarea-cambiada, nottareaCambiadanorTareaCambiada. Native browser event names (click,mouseenter) are treated case-insensitively internally, but for custom events,kebab-caseis the dominant convention, and the one Lit itself follows in its documentation and examples. - Without the
onprefix: a common mistake, especially for those coming from other frameworks, is to name the eventon-tarea-cambiada. Theonprefix is reserved, by DOM convention, for handler property names (onclick,onchange), not for the event names themselves; the event is calledclick, notonclick, and in the same way a custom event should be calledtarea-cambiada, noton-tarea-cambiada. - A name that describes the fact that occurred, not the action to perform:
tarea-cambiada(something that has already happened) is preferable tocambiar-tarea(a command). This reinforces the idea from section 1: the child announces a fact accomplished about itself, it does not give its parent an order about what to do.
- Adding a state selector to
<task-card>
<task-card>Until now, <task-card> only allowed seeing a task's state (with renderInsigniaEstado()), not changing it. To be able to fire the tarea-cambiada event with real data, we first need a way for the user to choose a new state from the card itself: a simple <select>, with the three options already used in renderInsigniaEstado().
// 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 sin cambios...
gestionarCambioDeSelector(event) {
// Stops propagation of the native "change" event from the <select>: the event
// that matters to whoever uses <task-card> is not "the select changed",
// but the custom event of its own dispatched right below.
event.stopPropagation();
const nuevoEstado = event.target.value;
this.estado = nuevoEstado;
this.notificarCambioDeEstado(nuevoEstado);
}
notificarCambioDeEstado(nuevoEstado) {
this.dispatchEvent(
new CustomEvent('tarea-cambiada', {
detail: { nuevoEstado },
bubbles: true,
composed: true,
})
);
}
renderSelectorEstado() {
return html`
<select @change="${this.gestionarCambioDeSelector}" .value="${this.estado}">
<option value="pendiente">Pendiente</option>
<option value="en-progreso">En progreso</option>
<option value="hecha">Hecha</option>
</select>
`;
}
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.expandida
? html`<div class="detalle"><p>Estado interno: la tarjeta está expandida.</p></div>`
: ''}
</article>
`;
}
}Two details deserve attention before moving on to the event itself. First, .value="${this.estado}" uses the dot property binding, already seen in previous modules, instead of a regular value attribute: this ensures the <select> always shows the option corresponding to the current value of this.estado, even if that value changes through a route other than the selector itself (for example, if in the future <task-list> were to push back down an updated estado property). Second, event.stopPropagation() inside gestionarCambioDeSelector prevents the native change event of the <select> from continuing to bubble upward: it wouldn't make sense for <task-list> (or any other ancestor) to have to distinguish between the generic change of a <select> internal to <task-card> and any other change that might occur elsewhere in the interface; the event that truly matters outward is the custom tarea-cambiada event, not the native event that originates it internally.
- Emitting
tarea-cambiada
tarea-cambiadaThe complete flow, from start to finish, is as follows: the user opens a card's <select> and chooses "Hecha"; the browser fires a native change event on the <select>; gestionarCambioDeSelector captures it, stops its propagation, updates this.estado (which, like any reactive property, triggers a new render of the card itself, now showing the badge corresponding to the new state) and calls notificarCambioDeEstado('hecha'); this method creates and dispatches a CustomEvent named tarea-cambiada, with detail: { nuevoEstado: 'hecha' }, configured with bubbles: true and composed: true so it can leave <task-card>'s shadow root and reach whoever is listening outside, typically <task-list>.
It's important to note that, at this point in the lesson, <task-card> has already done everything it's responsible for: it has updated its own appearance (via this.estado) and announced the change outward (via the event). It does not know, nor does it care, whether anyone is listening to that event, nor what that listener will do with the information received. That responsibility, deliberately, is moved to the next lesson, where <task-list> will listen for tarea-cambiada and decide what to do with it.
- Closing: who listens to this event
To verify, without yet revealing the full content of the next lesson, that the event really is dispatched and reaches outside the component, a minimal listener placed directly in HTML, outside any shadow root, is enough:
<task-card titulo="Revisar el PR de autenticación" estado="pendiente"></task-card>
<script>
document.querySelector('task-card').addEventListener('tarea-cambiada', (event) => {
console.log('Nuevo estado recibido en el padre:', event.detail.nuevoEstado);
});
</script>This listener, added with regular addEventListener on the element instance (exactly the same method seen in the previous lesson for native events, because, as explained in section 2, a CustomEvent is, at bottom, an Event just like any other), confirms that the event leaves <task-card>'s Shadow DOM and can be listened to from outside with the most basic DOM tools, with no need for the listener to be another Lit component. In TaskFlow's practice, however, whoever listens to this event will not be a loose script but <task-list>, using the same familiar declarative @event syntax, this time applied to a custom event instead of a native browser one.
Common Mistakes and Tips
- Forgetting
composed: true: it is, by far, the most frequent mistake when debugging "my custom event doesn't reach my parent." If the component dispatching the event has Shadow DOM (like anyLitElement), and the event doesn't carrycomposed: true, the bubbling stops exactly at the shadow root boundary, and no one outside that boundary will ever receive it, no matter how seemingly correct their listener is. - Forgetting
bubbles: true: without this option, the event does not propagate upward at all; only a listener placed directly on the exact element dispatching it would receive it, which, in the practice of communication between components, is almost never useful. - Naming the event with the
onprefix: as explained in section 5,on-tarea-cambiadabreaks the standard convention; the event name describes the fact that occurred (tarea-cambiada), never the prefix reserved for DOM handler properties. - Putting too much decision logic inside the component that dispatches the event:
<task-card>limits itself to announcing that the user has chosen a new state; it is not<task-card>'s job to decide, for example, whether that state change is valid according to some broader business rule (such as preventing marking a task as "done" if it has pending subtasks). Those decisions are the responsibility of whoever listens to the event, not whoever emits it. - Forgetting
event.stopPropagation()on the native event that originates the custom one: ifgestionarCambioDeSelectordoes not stop propagation of the<select>'s nativechange, both events (the nativechangeand the customtarea-cambiada) bubble outward, and any ancestor that happens to listen forchangegenerically (uncommon, but possible) would receive an event it has no business interpreting.
Exercises
- Add to
<task-card>a "Eliminar tarea" button that dispatches a new custom eventtarea-eliminada, with an emptydetail(no data needs to be carried, since whoever listens already knows which specific<task-card>instance the event occurred on), configured withbubbles: trueandcomposed: true. - Explain, based on section 3, what would happen if
<task-card>dispatchedtarea-cambiadawithbubbles: truebut withoutcomposed: true, in the specific case where<task-list>(which lives outside each<task-card>'s shadow root) had an@tarea-cambiadalistener set on each card. - A teammate proposes that, instead of dispatching an event,
<task-card>receive a callback function as a property (for example,.onCambioDeEstado="${miFuncion}") and call it directly when the user changes the state. Compare this alternative with the custom-event pattern seen in this lesson, pointing out at least one advantage of custom events over that alternative.
Solutions
notificarEliminacion() {
this.dispatchEvent(
new CustomEvent('tarea-eliminada', {
bubbles: true,
composed: true,
})
);
}
render() {
return html`
<article @click="${this.alternarExpandida}">
...
<button @click="${(event) => { event.stopPropagation(); this.notificarEliminacion(); }}">
Eliminar tarea
</button>
</article>
`;
}Note that here too event.stopPropagation() is needed inside the button's handler: without it, a click on "Eliminar tarea" would equally bubble up to the <article> and additionally trigger alternarExpandida, expanding or collapsing the card exactly when the user only wanted to delete it.
-
Without
composed: true, thetarea-cambiadaevent, even withbubbles: true, would stay trapped inside the shadow root of the<task-card>that dispatches it and would never manage to cross that boundary toward the light DOM outside, where<task-list>lives. The@tarea-cambiadalistener set by<task-list>on each<task-card>, even if syntactically well written, simply would never fire: the event would exist and would bubble, but only inside a tree (the shadow root of<task-card>) that<task-list>cannot see. -
The callback-as-property pattern would force
<task-list>to pass a specific function to each<task-card>, and that binding would be exclusive between those two specific instances: any other code that also wanted to find out about the state change (for example, a statistics component added later to TaskFlow) would have to modify<task-card>to accept a second callback function, or chain calls manually. With the event pattern, on the other hand, any number of listeners can listen totarea-cambiadaindependently, without<task-card>needing to know how many there are or modify anything to admit one more; it's the same standard mechanism that already lets a native button have several simultaneousaddEventListener('click', ...)calls without conflict between them.
Conclusion
This lesson has solved the core problem of child-to-parent communication in Web Components: why a child component should not directly touch its parent's state, how to build a CustomEvent with its own data in detail, why both bubbles: true and composed: true are needed for the event to cross the Shadow DOM boundary, how to dispatch it with dispatchEvent, and the lowercase-hyphenated naming convention, without the on prefix. As a result, <task-card> now has a real state selector that emits tarea-cambiada every time the user chooses a new value.
One piece, however, is still missing: <task-card> announces the change, but no one is yet picking it up in any useful way. In the next lesson, "Communication from Parent to Child with Properties," <task-list> will listen for tarea-cambiada with @tarea-cambiada, will update its own tareas array immutably, and that updated array will flow back down as a property to every card, thereby closing the full communication cycle between <task-card> and <task-list>.
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
