<task-card> sabe, desde el módulo 3, reaccionar a un clic modificando su propio estado interno. Pero un vistazo honesto a TaskFlow deja claro que eso no basta: si el usuario quiere marcar una tarea como "hecha" desde su tarjeta, ese cambio tiene que llegar de algún modo a <task-list>, que es quien mantiene el array real de tareas. Esta lección resuelve ese problema con el mecanismo estándar de los Web Components para que un hijo se comunique con quien lo contiene: los eventos personalizados, construidos con CustomEvent, despachados con dispatchEvent, y diseñados para atravesar limpiamente la frontera del Shadow DOM. Al final de la lección, <task-card> tendrá un pequeño selector de estado y emitirá un evento tarea-cambiada cada vez que el usuario lo use.
Contenido
- Por qué un hijo no debe tocar el estado de su padre directamente
CustomEvent: un evento con datos propiosbubblesycomposed: cómo viaja un evento a través del Shadow DOM- Despachar el evento con
dispatchEvent - Convención de nombres para eventos personalizados
- Añadiendo un selector de estado a
<task-card> - Emitiendo
tarea-cambiada - Cierre: quién escucha este evento
- Por qué un hijo no debe tocar el estado de su padre directamente
Imagina, por un momento, una solución tentadora pero equivocada al problema planteado en la introducción: que <task-card> reciba, como propiedad, una referencia directa al array tareas de <task-list> (o incluso una referencia al propio componente <task-list>), y que al cambiar el estado de una tarea, <task-card> modifique ese array directamente, buscando el elemento que le corresponde y mutándolo en el sitio.
Este enfoque, aunque parezca ahorrar código, rompe una de las ideas más importantes del diseño de componentes bien encapsulados: un componente hijo no debería conocer, y mucho menos modificar, la estructura interna de datos de su padre. Si <task-card> tuviera que saber que vive dentro de un array gestionado por <task-list>, dejaría de ser un componente reutilizable de forma aislada: no se podría usar <task-card> suelta en ninguna otra parte de la aplicación (una vista de detalle de una sola tarea, por ejemplo) sin arrastrar consigo esa dependencia hacia una estructura de datos que, en ese otro contexto, ni siquiera existiría.
La alternativa correcta, y la que sigue el estándar de Web Components desde su origen, es justo la contraria en cuanto a dirección de la responsabilidad: el hijo no cambia nada por su cuenta en el padre; simplemente anuncia que algo ha ocurrido, sin saber ni necesitar saber quién lo está escuchando ni qué va a hacer con esa información. Es responsabilidad exclusiva de quien escucha (normalmente el padre, aunque no necesariamente) decidir qué hacer con el aviso: actualizar su propio estado, ignorarlo, o reenviarlo a su vez hacia arriba. Este patrón —hijo anuncia, padre decide— es exactamente para lo que existen los eventos personalizados.
CustomEvent: un evento con datos propios
CustomEvent: un evento con datos propiosEl navegador permite crear eventos propios, no nativos, mediante el constructor CustomEvent, que es una subclase de la clase Event estándar (la misma familia que los eventos de clic o teclado ya vistos en la lección anterior). Su diferencia principal frente a un evento nativo es que se puede adjuntar cualquier dato personalizado a través de la propiedad detail:
El primer argumento del constructor es el nombre del evento (una cadena de texto elegida libremente, sobre cuya convención se hablará en el apartado 5); el segundo es un objeto de opciones donde detail puede contener cualquier valor de JavaScript: un objeto, un array, un número, incluso undefined si el evento no necesita transportar ningún dato adicional más allá del hecho de haberse producido. Quien escuche este evento accederá a esa información leyendo event.detail, exactamente igual que se lee cualquier otra propiedad del objeto evento.
bubbles y composed: cómo viaja un evento a través del Shadow DOM
bubbles y composed: cómo viaja un evento a través del Shadow DOMCrear un CustomEvent no basta por sí solo para que llegue a donde hace falta: por defecto, un evento no burbujea (no se propaga hacia los elementos ancestros) y, si el elemento que lo despacha vive dentro de un shadow root, el evento no sale de esa frontera de Shadow DOM. Ambos comportamientos se activan explícitamente con dos opciones del constructor:
const evento = new CustomEvent('tarea-cambiada', {
detail: { id: 3, nuevoEstado: 'hecha' },
bubbles: true,
composed: true,
});bubbles: truehace que el evento, tras dispararse en el elemento origen, se propague hacia arriba a través de todos sus ancestros en el árbol del DOM, exactamente el mismo mecanismo de burbujeo mencionado en la lección anterior a propósito deevent.target. Sin esta opción, el evento solo lo podría escuchar quien tenga un listener puesto directamente sobre el elemento que lo despacha, lo cual es casi inútil en la práctica: nadie fuera del propio componente tiene, ni debería tener, una referencia directa al elemento interno concreto que originó el evento.composed: truees la opción específica de Web Components, y la que suele generar más dudas: hace que el evento pueda atravesar la frontera del Shadow DOM, es decir, salir del shadow root donde se originó hacia el DOM ligero exterior. Recuerda que, como se explicó en el módulo 4, el Shadow DOM encapsula deliberadamente lo que ocurre dentro de un componente; esa misma encapsulación, sincomposed: true, detendría el burbujeo del evento justo en el límite del shadow root, e impediría que<task-list>, que vive fuera del shadow root de<task-card>, llegara a enterarse de nada.
La combinación de ambas opciones es, en la práctica, la que se usa casi universalmente para eventos personalizados que un componente despacha con intención de que su padre (o cualquier ancestro) lo escuche: sin bubbles: true el evento no sube; sin composed: true, aunque suba, se queda atrapado dentro del propio shadow root del componente que lo origina. Olvidar cualquiera de las dos es, con diferencia, el error más frecuente al trabajar con eventos personalizados en Web Components, y se detalla en el apartado de errores comunes de esta lección.
- Despachar el evento con
dispatchEvent
dispatchEventUn CustomEvent, una vez creado, no ocurre por sí solo: hay que despacharlo explícitamente sobre un elemento del DOM, mediante el método dispatchEvent, heredado por todo elemento (incluida cualquier clase que extienda LitElement, que a su vez extiende HTMLElement) directamente de la plataforma web:
class TaskCard extends LitElement {
notificarCambioDeEstado(nuevoEstado) {
this.dispatchEvent(
new CustomEvent('tarea-cambiada', {
detail: { nuevoEstado },
bubbles: true,
composed: true,
})
);
}
}this.dispatchEvent(...), llamado sobre la propia instancia del componente, hace que el evento se origine exactamente en <task-card> (el elemento personalizado en sí, no un nodo interno de su shadow root), y a partir de ahí, gracias a bubbles: true y composed: true, se propague hacia arriba a través del DOM ligero exterior: primero hacia su elemento padre inmediato (normalmente el <div class="lista"> de <task-list>), después hacia <task-list> mismo, y así sucesivamente hacia cualquier ancestro que tenga un listener puesto para tarea-cambiada.
Nótese que dispatchEvent es una llamada síncrona: todos los listeners que estén escuchando ese evento en ese momento se ejecutan inmediatamente, antes de que dispatchEvent termine de ejecutarse y el código continúe en la línea siguiente. Esto no suele tener implicaciones prácticas en el uso habitual de este curso, pero conviene saberlo si alguna vez se necesita razonar sobre el orden exacto en que se ejecutan distintas piezas de código.
- Convención de nombres para eventos personalizados
El nombre elegido para un evento personalizado es una cadena de texto libre, pero la comunidad de Web Components sigue, de forma casi universal, una convención sencilla que conviene respetar:
- Minúsculas y con guiones (
kebab-case), igual que los nombres de los propios elementos personalizados:tarea-cambiada, notareaCambiadaniTareaCambiada. Los nombres de eventos nativos del navegador (click,mouseenter) no distinguen mayúsculas de minúsculas en su tratamiento interno, pero para eventos personalizados,kebab-casees la convención dominante y la que sigue el propio Lit en su documentación y ejemplos. - Sin el prefijo
on: un error frecuente, sobre todo en quien viene de otros frameworks, es nombrar el eventoon-tarea-cambiada. El prefijoonse reserva, por convención del DOM, para los nombres de las propiedades manejadoras (onclick,onchange), no para los nombres de los eventos en sí; el evento se llamaclick, noonclick, y de la misma forma el evento personalizado debería llamarsetarea-cambiada, noon-tarea-cambiada. - Un nombre que describa el hecho ocurrido, no la acción a realizar:
tarea-cambiada(algo que ya ha pasado) es preferible acambiar-tarea(una orden). Esto refuerza la idea del apartado 1: el hijo anuncia un hecho consumado sobre sí mismo, no da una orden a su padre sobre qué hacer.
- Añadiendo un selector de estado a
<task-card>
<task-card>Hasta ahora, <task-card> solo permitía ver el estado de una tarea (con renderInsigniaEstado()), no cambiarlo. Para poder disparar el evento tarea-cambiada con un dato real, hace falta primero una forma de que el usuario elija un nuevo estado desde la propia tarjeta: un <select> sencillo, con las tres opciones ya usadas en 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) {
// Detiene la propagación del "change" nativo del <select>: el evento
// que le interesa a quien use <task-card> no es "el select ha cambiado",
// sino el evento personalizado propio que se despacha justo debajo.
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>
`;
}
}Dos detalles merecen atención antes de pasar al evento en sí. Primero, .value="${this.estado}" usa el binding de propiedad con punto, ya visto en módulos anteriores, en lugar de un atributo value normal: esto asegura que el <select> muestre siempre la opción correspondiente al valor actual de this.estado, incluso si ese valor cambia por una vía distinta al propio selector (por ejemplo, si en el futuro <task-list> volviera a bajar una propiedad estado actualizada). Segundo, event.stopPropagation() dentro de gestionarCambioDeSelector evita que el evento nativo change del <select> siga burbujeando hacia arriba: no tendría sentido que <task-list> (o cualquier otro ancestro) tuviera que distinguir entre el change genérico de un <select> interno de <task-card> y cualquier otro change que pudiera producirse en otro lugar de la interfaz; el evento realmente relevante hacia fuera es el evento personalizado tarea-cambiada, no el evento nativo que lo origina internamente.
- Emitiendo
tarea-cambiada
tarea-cambiadaEl flujo completo, de principio a fin, queda así: el usuario abre el <select> de una tarjeta y elige "Hecha"; el navegador dispara un evento nativo change sobre el <select>; gestionarCambioDeSelector lo captura, detiene su propagación, actualiza this.estado (lo que dispara, como cualquier propiedad reactiva, un nuevo renderizado de la propia tarjeta, ahora mostrando la insignia correspondiente al nuevo estado) y llama a notificarCambioDeEstado('hecha'); este método crea y despacha un CustomEvent llamado tarea-cambiada, con detail: { nuevoEstado: 'hecha' }, configurado con bubbles: true y composed: true para que pueda salir del shadow root de <task-card> y llegar hasta quien esté escuchando fuera, típicamente <task-list>.
Es importante notar que, en este punto de la lección, <task-card> ya ha hecho todo lo que le corresponde: ha actualizado su propia apariencia (mediante this.estado) y ha anunciado el cambio hacia fuera (mediante el evento). No sabe, ni le importa, si alguien está escuchando ese evento, ni qué va a hacer esa escucha con la información recibida. Esa responsabilidad, deliberadamente, se traslada a la lección siguiente, donde <task-list> escuchará tarea-cambiada y decidirá qué hacer con ella.
- Cierre: quién escucha este evento
Para comprobar, sin adelantar todavía el contenido completo de la lección siguiente, que el evento realmente se despacha y llega hasta fuera del componente, basta un listener mínimo colocado directamente en HTML, fuera de cualquier shadow root:
<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>Este listener, añadido con addEventListener normal sobre la instancia del elemento (exactamente el mismo método visto en la lección anterior para eventos nativos, porque, como se explicó en el apartado 2, un CustomEvent es, en el fondo, un Event como cualquier otro), confirma que el evento sale del Shadow DOM de <task-card> y se puede escuchar desde fuera con las herramientas más básicas del DOM, sin ninguna necesidad de que quien escuche sea otro componente de Lit. En la práctica de TaskFlow, sin embargo, quien escuchará este evento no será un script suelto sino <task-list>, mediante la misma sintaxis declarativa @evento ya conocida, aplicada esta vez a un evento personalizado en lugar de a uno nativo del navegador.
Errores Comunes y Consejos
- Olvidar
composed: true: es, con diferencia, el error más frecuente al depurar "mi evento personalizado no llega a mi padre". Si el componente que despacha el evento tiene Shadow DOM (como cualquierLitElement), y el evento no llevacomposed: true, el burbujeo se detiene exactamente en el límite del shadow root, y nadie fuera de ese límite lo recibirá jamás, por mucho que tenga puesto un listener aparentemente correcto. - Olvidar
bubbles: true: sin esta opción, el evento no se propaga en absoluto hacia arriba; solo lo recibiría un listener puesto directamente sobre el elemento exacto que lo despacha, lo cual, en la práctica de comunicación entre componentes, casi nunca es útil. - Nombrar el evento con el prefijo
on: como se explicó en el apartado 5,on-tarea-cambiadarompe la convención estándar; el nombre del evento describe el hecho ocurrido (tarea-cambiada), nunca lleva el prefijo reservado para las propiedades manejadoras del DOM. - Meter demasiada lógica de decisión dentro del componente que despacha el evento:
<task-card>se limita a anunciar que el usuario ha elegido un nuevo estado; no le corresponde a<task-card>decidir, por ejemplo, si ese cambio de estado es válido según alguna regla de negocio más amplia (como impedir marcar una tarea como "hecha" si tiene subtareas pendientes). Esas decisiones son responsabilidad de quien escucha el evento, no de quien lo emite. - Olvidar
event.stopPropagation()en el evento nativo que origina el personalizado: sigestionarCambioDeSelectorno detiene la propagación delchangenativo del<select>, ambos eventos (elchangenativo y eltarea-cambiadapersonalizado) burbujean hacia fuera, y cualquier ancestro que por casualidad escuchechangede forma genérica (poco habitual, pero posible) recibiría un evento que no le corresponde interpretar.
Ejercicios
- Añade a
<task-card>un botón "Eliminar tarea" que despache un nuevo evento personalizadotarea-eliminada, condetailvacío (no hace falta transportar ningún dato, ya que quien escuche ya sabe sobre qué instancia concreta de<task-card>se ha producido el evento), configurado conbubbles: trueycomposed: true. - Explica, basándote en el apartado 3, qué pasaría si
<task-card>despacharatarea-cambiadaconbubbles: truepero sincomposed: true, en el caso concreto de que<task-list>(que vive fuera del shadow root de cada<task-card>) tuviera un listener@tarea-cambiadapuesto sobre cada tarjeta. - Un compañero de equipo propone que, en lugar de despachar un evento,
<task-card>reciba una función de callback como propiedad (por ejemplo,.onCambioDeEstado="${miFuncion}") y la llame directamente cuando el usuario cambie el estado. Compara esta alternativa con el patrón de eventos personalizados visto en esta lección, señalando al menos una ventaja de los eventos personalizados frente a esa alternativa.
Soluciones
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>
`;
}Nótese que aquí también hace falta event.stopPropagation() dentro del manejador del botón: sin ella, el clic sobre "Eliminar tarea" burbujearía igualmente hasta el <article> y dispararía además alternarExpandida, expandiendo o contrayendo la tarjeta justo cuando el usuario solo quería eliminarla.
-
Sin
composed: true, el eventotarea-cambiada, aunque tengabubbles: true, quedaría atrapado dentro del shadow root de la propia<task-card>que lo despacha y nunca llegaría a atravesar esa frontera hacia el DOM ligero exterior, donde vive<task-list>. El listener@tarea-cambiadapuesto por<task-list>sobre cada<task-card>, aunque esté sintácticamente bien escrito, simplemente no se dispararía nunca: el evento existiría y burbujearía, pero solo dentro de un árbol (el shadow root de<task-card>) que<task-list>no puede ver. -
El patrón de callback como propiedad obligaría a
<task-list>a pasar una función concreta a cada<task-card>, y ese enlace sería exclusivo entre esas dos instancias concretas: cualquier otro código que quisiera enterarse también del cambio de estado (por ejemplo, un componente de estadísticas que se añadiera más adelante a TaskFlow) tendría que modificar<task-card>para aceptar una segunda función de callback, o encadenar llamadas manualmente. Con el patrón de eventos, en cambio, cualquier número de listeners puede escuchartarea-cambiadade forma independiente, sin que<task-card>necesite saber cuántos son ni modificar nada para admitir uno más; es el mismo mecanismo estándar que ya permite que un botón nativo tenga variosaddEventListener('click', ...)simultáneos sin conflicto entre ellos.
Conclusión
En esta lección se ha resuelto el problema central de la comunicación de hijo a padre en Web Components: por qué un componente hijo no debe tocar directamente el estado de su padre, cómo construir un CustomEvent con datos propios en detail, por qué hacen falta tanto bubbles: true como composed: true para que el evento atraviese la frontera del Shadow DOM, cómo despacharlo con dispatchEvent, y la convención de nombres en minúsculas y guiones, sin el prefijo on. <task-card> ya tiene, como resultado, un selector de estado real que emite tarea-cambiada cada vez que el usuario elige un nuevo valor.
Queda, sin embargo, una pieza pendiente: <task-card> anuncia el cambio, pero nadie lo está recogiendo todavía de forma útil. En la siguiente lección, "Comunicación de Padre a Hijo con Propiedades", <task-list> escuchará tarea-cambiada con @tarea-cambiada, actualizará su propio array tareas de forma inmutable, y ese array actualizado volverá a bajar como propiedad hacia todas las tarjetas, cerrando así el ciclo completo de comunicación entre <task-card> y <task-list>.
Curso de Lit
Módulo 1: Introducción a Lit y Web Components
- ¿Qué son los Web Components y por qué Lit?
- Configuración del Entorno de Desarrollo
- Tu Primer Componente Lit
- Anatomía de un Componente Lit
Módulo 2: Plantillas Reactivas y Renderizado
- El Motor de Plantillas de Lit
- Expresiones e Interpolación en Plantillas
- Renderizado Condicional
- Renderizado de Listas
- El Ciclo de Renderizado
Módulo 3: Propiedades y Estado Reactivo
- Propiedades Reactivas
- Estado Interno con @state
- Tipos de Propiedades y Conversores Personalizados
- Atributos vs Propiedades y Reflexión
Módulo 4: Estilos en Componentes Lit
- CSS Encapsulado con Shadow DOM
- Estilos Compartidos entre Componentes
- Variables CSS Personalizadas y Theming
- Slots y Estilizado de Contenido Distribuido
Módulo 5: Eventos y Comunicación entre Componentes
- Manejo de Eventos DOM en Plantillas
- Eventos Personalizados: Comunicación Hijo a Padre
- Comunicación de Padre a Hijo con Propiedades
- Patrones de Comunicación entre Componentes Hermanos
Módulo 6: Ciclo de Vida y Comportamiento Avanzado
- Callbacks del Ciclo de Vida
- Hooks Reactivos: willUpdate, updated y firstUpdated
- Controladores Reactivos
- Mixins y Composición de Comportamiento
Módulo 7: Directivas y Funcionalidades Avanzadas de Plantillas
- Directivas Incorporadas: classMap, styleMap e ifDefined
- Directivas Personalizadas
- Renderizado Asíncrono con until
- Contexto Compartido con @lit/context
Módulo 8: Integración, Interoperabilidad y Despliegue
- Usar Componentes Lit en HTML Plano
- Integrar Lit con React, Vue y Angular
- Renderizado en el Servidor con @lit-labs/ssr
- Empaquetado, Publicación y TypeScript
Módulo 9: Pruebas y Buenas Prácticas
- Pruebas Unitarias con Web Test Runner
- Accesibilidad en Web Components
- Rendimiento y Optimización
- Patrones y Anti-patrones Comunes
