Un clic que viaja más lejos de lo que parece
Cuando un usuario hace clic en un botón de una página web, la intuición dice que el evento ocurre únicamente en ese botón. Sin embargo, el navegador ve las cosas de otra manera: ese botón está dentro de un contenedor, que a su vez está dentro de una sección, que está dentro del body, que forma parte del documento. Desde el punto de vista del DOM, el clic también ocurrió en todos esos ancestros, y el navegador se encarga de avisarles uno por uno.
Este comportamiento se llama event bubbling, o burbujeo de eventos, porque el evento “sube” desde el elemento donde se originó hasta la raíz del documento, como una burbuja que asciende en un vaso de agua. Es una de las piezas más importantes del modelo de eventos del navegador y, a la vez, una de las que más confusión genera: listeners que se disparan cuando no deberían, menús que se cierran solos, modales que se abren dos veces.
En este artículo vamos a entender a fondo la propagación de eventos en JavaScript: las tres fases que recorre un evento, cómo observar el burbujeo, la diferencia entre las propiedades target y currentTarget, cómo detener la propagación y cuándo no deberías hacerlo, y la técnica que convierte el burbujeo en una herramienta poderosa: la delegación de eventos. Todo lo que veremos forma parte del estándar DOM y funciona en todos los navegadores modernos sin librerías adicionales.
Las tres fases de un evento del DOM
Antes de observar el burbujeo conviene conocer el recorrido completo. Cada evento que se despacha sobre un elemento del DOM atraviesa tres fases consecutivas:
- Fase de captura: el evento parte de
window, baja pordocument,html,bodyy cada ancestro hasta llegar al padre del elemento de destino. - Fase de destino: el evento llega al elemento donde realmente ocurrió la acción.
- Fase de burbujeo: el evento recorre el camino inverso, subiendo desde el destino hasta
window.
Puedes imaginarlo como un mensajero que baja por una escalera hasta el piso donde está el destinatario y luego vuelve a subir. En cada escalón, tanto de bajada como de subida, cualquier listener registrado para esa fase tiene la oportunidad de reaccionar.
El objeto del evento indica en qué fase se encuentra mediante la propiedad eventPhase, que devuelve un número:
// Valores posibles de event.eventPhase
Event.NONE; // 0: el evento no se está procesando
Event.CAPTURING_PHASE; // 1: bajando hacia el destino
Event.AT_TARGET; // 2: en el elemento de destino
Event.BUBBLING_PHASE; // 3: subiendo hacia la raíz
Por defecto, addEventListener registra los listeners para la fase de burbujeo. Por eso, en la práctica diaria, el burbujeo es la fase con la que más interactúas, y es el foco principal de este tutorial. Más adelante veremos cómo escuchar también en la fase de captura.
Montar un ejemplo para ver el burbujeo
La mejor manera de entender el burbujeo es verlo en acción. Vamos a construir una pequeña tarjeta de producto de una tienda en línea, con varios niveles de anidación y un color distinto para cada uno, de modo que sea evidente dónde haces clic.
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8" />
<title>Burbujeo de eventos</title>
<link rel="stylesheet" href="estilos.css" />
</head>
<body>
<article class="tarjeta">
<footer class="pie">
<button class="agregar">Añadir al carrito</button>
</footer>
</article>
<script src="eventos.js"></script>
</body>
</html>
Los estilos solo sirven para distinguir visualmente cada capa:
body {
background: #f6d7e2;
padding: 24px;
font-family: system-ui, sans-serif;
}
.tarjeta {
background: #cfe8cf;
padding: 24px;
width: max-content;
}
.pie {
display: block;
background: #c9dcf5;
padding: 24px;
}
La estructura resultante del DOM es una cadena de ancestros muy clara:
html
└── body
└── article.tarjeta
└── footer.pie
└── button.agregar
Con este marcado tenemos cuatro zonas donde hacer clic: el fondo rosado del body, el verde de la tarjeta, el azul del pie y el propio botón.
Observar el recorrido con addEventListener
Ahora registramos un listener de clic en cada uno de los cuatro elementos. Usaremos querySelector, que es la forma más habitual hoy en día de seleccionar elementos individuales:
// eventos.js
const cuerpo = document.body;
const tarjeta = document.querySelector(".tarjeta");
const pie = document.querySelector(".pie");
const boton = document.querySelector(".agregar");
cuerpo.addEventListener("click", () => console.log("clic en body"));
tarjeta.addEventListener("click", () => console.log("clic en la tarjeta"));
pie.addEventListener("click", () => console.log("clic en el pie"));
boton.addEventListener("click", () => console.log("clic en el botón"));
Abre la página, despliega la consola de las herramientas de desarrollo y prueba cada zona. Si haces clic en el fondo rosado, solo responde el body:
clic en body
Si haces clic en la zona verde de la tarjeta, el evento se origina en ella y sube al body:
clic en la tarjeta
clic en body
Y si haces clic en el botón, el evento recorre toda la cadena:
clic en el botón
clic en el pie
clic en la tarjeta
clic en body
El orden siempre es del elemento más interno al más externo. Ese es el burbujeo en estado puro: un solo clic dispara cuatro listeners distintos, cada uno registrado en un nivel de la jerarquía.
Fíjate en que no hemos hecho nada especial para que esto ocurra. Es el comportamiento predeterminado del navegador para la gran mayoría de eventos de interacción, como click, input, keydown o submit.
Quién originó el evento: target y currentTarget
Cuando un mismo evento pasa por varios listeners, cada uno necesita saber dos cosas distintas: dónde se originó la acción y en qué elemento se está ejecutando el listener en ese momento. El objeto del evento responde a ambas preguntas con dos propiedades.
La propiedad target apunta al elemento donde ocurrió el evento originalmente, el más profundo de la cadena. No cambia durante todo el recorrido. La propiedad currentTarget, en cambio, apunta al elemento cuyo listener se está ejecutando, y va cambiando a medida que el evento sube.
Entender la diferencia entre target y currentTarget es esencial para escribir manejadores correctos. Veámoslo con un listener reutilizable:
function registrar(evento) {
console.log(
`currentTarget: ${evento.currentTarget.className || evento.currentTarget.tagName}`,
`| target: ${evento.target.className}`,
`| fase: ${evento.eventPhase}`
);
}
[document.body, tarjeta, pie, boton].forEach((el) =>
el.addEventListener("click", registrar)
);
Al hacer clic en el botón, la salida es:
currentTarget: agregar | target: agregar | fase: 2
currentTarget: pie | target: agregar | fase: 3
currentTarget: tarjeta | target: agregar | fase: 3
currentTarget: BODY | target: agregar | fase: 3
El target siempre es el botón, mientras que currentTarget va subiendo. La fase también cambia: en el botón estamos en la fase de destino, y en los ancestros, en la de burbujeo.
Un detalle importante: dentro de una función declarada con function, la palabra clave this equivale a currentTarget. En una función flecha, this no se enlaza al elemento, así que conviene usar siempre evento.currentTarget para evitar sorpresas. Además, currentTarget solo es válido mientras se ejecuta el listener; si lo lees después, dentro de un setTimeout o tras un await, valdrá null.
tarjeta.addEventListener("click", async (evento) => {
console.log(evento.currentTarget); // <article class="tarjeta">
await Promise.resolve();
console.log(evento.currentTarget); // null
});
Si necesitas el elemento más adelante, guárdalo en una variable al inicio del listener.
Escuchar en la fase de captura
Aunque el burbujeo es la fase por defecto, puedes pedir que un listener se ejecute durante la captura, es decir, mientras el evento baja hacia el destino. Para ello, pasa un objeto de opciones con capture: true como tercer argumento:
tarjeta.addEventListener("click", () => console.log("tarjeta (captura)"), {
capture: true,
});
tarjeta.addEventListener("click", () => console.log("tarjeta (burbujeo)"));
boton.addEventListener("click", () => console.log("botón"));
Al hacer clic en el botón, el orden revela el recorrido de ida y vuelta:
tarjeta (captura)
botón
tarjeta (burbujeo)
La captura es útil cuando un ancestro necesita actuar antes que cualquier descendiente. Un ejemplo real es un sistema de analítica que registra todos los clics de la página aunque algún componente detenga la propagación en la fase de burbujeo, o un modo de “solo lectura” que bloquea interacciones antes de que lleguen a los controles.
Ten en cuenta que, para eliminar un listener registrado en captura, debes indicar también la opción de captura en removeEventListener; de lo contrario, el navegador no lo encontrará:
function auditar(evento) {
console.log("auditoría:", evento.target);
}
document.addEventListener("click", auditar, { capture: true });
document.removeEventListener("click", auditar, { capture: true });
Detener la propagación con stopPropagation
El burbujeo es útil, pero hay situaciones en las que no lo quieres. Imagina que la tarjeta completa abre una vista de detalle del producto al hacer clic, mientras que el botón debe añadir el artículo al carrito sin abrir esa vista. Con el burbujeo por defecto, un clic en el botón haría ambas cosas.
Para detener la propagación del evento en un punto concreto, el objeto del evento ofrece el método stopPropagation(). Al llamarlo, el evento termina de procesarse en el elemento actual y no continúa hacia los ancestros:
tarjeta.addEventListener("click", () => {
console.log("Abriendo detalle del producto");
});
boton.addEventListener("click", (evento) => {
evento.stopPropagation();
console.log("Producto añadido al carrito");
});
Ahora, al hacer clic en el botón:
Producto añadido al carrito
La tarjeta no recibe el evento. Si haces clic en cualquier otra parte de la tarjeta, la vista de detalle se abre con normalidad.
El punto de corte puede estar en cualquier nivel. Si llamas a stopPropagation() en el listener del pie, un clic en el botón llegará al botón y al pie, pero no a la tarjeta ni al body:
pie.addEventListener("click", (evento) => {
evento.stopPropagation();
console.log("clic en el pie");
});
clic en el botón
clic en el pie
Existe una variante más estricta: stopImmediatePropagation(). Además de detener la subida, impide que se ejecuten otros listeners registrados en el mismo elemento para el mismo evento:
boton.addEventListener("click", (evento) => {
evento.stopImmediatePropagation();
console.log("primer listener");
});
boton.addEventListener("click", () => {
console.log("segundo listener"); // nunca se ejecuta
});
Los listeners de un mismo elemento se ejecutan en el orden en que se registraron, así que el que llama a stopImmediatePropagation() debe haberse registrado primero.
stopPropagation frente a preventDefault
Estos dos métodos se confunden con frecuencia porque ambos “detienen algo”, pero actúan sobre aspectos completamente distintos del evento.
stopPropagation() controla el recorrido del evento por el DOM: impide que llegue a otros elementos. No afecta a lo que el navegador hace por defecto ante esa acción.
preventDefault() controla la acción predeterminada del navegador: impide que un enlace navegue, que un formulario se envíe, que una casilla se marque o que una tecla escriba un carácter. No afecta al recorrido: el evento sigue burbujeando con normalidad.
const enlace = document.querySelector("a.terminos");
enlace.addEventListener("click", (evento) => {
evento.preventDefault(); // no navega a la URL
abrirModalTerminos();
// el clic sigue subiendo a los ancestros
});
document.body.addEventListener("click", (evento) => {
console.log("El body también se enteró del clic", evento.defaultPrevented);
});
El body también se enteró del clic true
La propiedad defaultPrevented permite a los ancestros saber si alguien ya canceló la acción por defecto, lo cual es una alternativa mucho más respetuosa que cortar la propagación: el evento llega a todos, pero cada uno puede decidir si actuar.
Puedes usar ambos métodos a la vez cuando necesitas los dos efectos, aunque conviene preguntarse si realmente hace falta detener el burbujeo. Un apunte para quien venga de jQuery: allí, devolver false desde un manejador equivalía a llamar a los dos métodos. En los listeners nativos registrados con addEventListener, devolver false no tiene ningún efecto.
Delegación de eventos: el burbujeo como herramienta
Hasta ahora hemos visto el burbujeo como algo que a veces hay que controlar. Pero su mayor virtud es que permite un patrón muy eficiente: la delegación de eventos. En lugar de registrar un listener en cada elemento hijo, registras uno solo en un ancestro común y, cuando el evento sube, identificas qué hijo lo originó.
Supongamos una lista de tareas donde cada elemento tiene botones para completar y eliminar:
<ul id="tareas">
<li data-id="1">
<span>Revisar pull request</span>
<button data-accion="completar">✔</button>
<button data-accion="eliminar">✖</button>
</li>
<li data-id="2">
<span>Actualizar dependencias</span>
<button data-accion="completar">✔</button>
<button data-accion="eliminar">✖</button>
</li>
</ul>
Con la delegación de eventos con closest, un único listener en la lista gestiona todos los botones, presentes y futuros:
const lista = document.querySelector("#tareas");
lista.addEventListener("click", (evento) => {
const boton = evento.target.closest("button[data-accion]");
if (!boton || !lista.contains(boton)) return;
const item = boton.closest("li");
const id = item.dataset.id;
switch (boton.dataset.accion) {
case "completar":
item.classList.toggle("completada");
break;
case "eliminar":
item.remove();
break;
}
console.log(`Acción "${boton.dataset.accion}" en la tarea ${id}`);
});
El método closest busca, empezando por el propio elemento, el ancestro más cercano que coincida con el selector. Esto es crucial: si el botón contuviera un icono SVG o un span, el target sería ese elemento interno y no el botón. Con closest subimos hasta encontrar el botón real. La comprobación con contains evita reaccionar a coincidencias fuera de la lista en estructuras anidadas complejas.
Las ventajas de este patrón son considerables:
- Un solo listener en lugar de dos por tarea, lo que reduce memoria y trabajo de registro.
- Las tareas añadidas dinámicamente funcionan sin código adicional, porque el listener está en el contenedor y no en ellas.
- No hay que eliminar listeners al borrar elementos, lo que previene fugas de memoria.
- La lógica de interacción queda centralizada en un punto.
Añadir una tarea nueva es tan simple como insertar el HTML; los botones responderán automáticamente:
function agregarTarea(id, texto) {
lista.insertAdjacentHTML(
"beforeend",
`<li data-id="${id}">
<span></span>
<button data-accion="completar">✔</button>
<button data-accion="eliminar">✖</button>
</li>`
);
lista.lastElementChild.querySelector("span").textContent = texto;
}
agregarTarea(3, "Escribir pruebas");
Observa que el texto de la tarea se asigna con textContent y no se interpola dentro del HTML. Si el texto viniera de un usuario, insertarlo directamente en una plantilla de HTML abriría la puerta a ataques de inyección de scripts.
Eventos que no burbujean
No todos los eventos del DOM burbujean. Algunos se despachan solo en el elemento de destino, y si intentas delegarlos en un ancestro, simplemente no llegarán. Conocer los eventos que no burbujean te ahorrará mucha depuración.
Los casos más habituales son:
focusyblur, que se disparan al ganar y perder el foco.mouseenterymouseleave, que se disparan al entrar y salir del área de un elemento.load,erroryaborten recursos como imágenes y scripts.scrollen elementos individuales, que no burbujea hasta el documento.
Para casi todos existen alternativas que sí burbujean. En lugar de focus y blur, usa focusin y focusout; en lugar de mouseenter y mouseleave, usa mouseover y mouseout:
const formulario = document.querySelector("#registro");
// No funciona: focus no burbujea hasta el formulario
formulario.addEventListener("focus", () => console.log("nunca se ve"));
// Funciona: focusin sí burbujea
formulario.addEventListener("focusin", (evento) => {
evento.target.closest(".campo")?.classList.add("activo");
});
formulario.addEventListener("focusout", (evento) => {
evento.target.closest(".campo")?.classList.remove("activo");
});
Otra alternativa es escuchar en la fase de captura, ya que todos los eventos, burbujeen o no, atraviesan la captura. Por ejemplo, registrar focus con { capture: true } en el formulario sí detectará el foco de sus campos.
Puedes comprobar si un evento concreto burbujea consultando su propiedad bubbles:
document.addEventListener("focusin", (evento) => {
console.log(evento.type, "burbujea:", evento.bubbles);
});
focusin burbujea: true
Eventos personalizados que burbujean
Además de los eventos del navegador, puedes crear los tuyos con CustomEvent. Por defecto, un evento personalizado no burbujea, así que debes activarlo explícitamente con la opción bubbles. Esto permite que un componente notifique algo a sus ancestros sin conocerlos directamente:
// Dentro de un componente de valoración con estrellas
function notificarValoracion(elemento, estrellas) {
elemento.dispatchEvent(
new CustomEvent("valoracion-cambiada", {
bubbles: true,
detail: { estrellas },
})
);
}
// En un ancestro, por ejemplo la página de producto
document
.querySelector(".ficha-producto")
.addEventListener("valoracion-cambiada", (evento) => {
console.log(`Nueva valoración: ${evento.detail.estrellas} estrellas`);
});
notificarValoracion(document.querySelector(".estrellas"), 4);
Nueva valoración: 4 estrellas
Este patrón desacopla los componentes: el selector de estrellas no necesita saber quién escucha, y cualquier ancestro puede reaccionar. Es la misma filosofía que usan los eventos nativos.
Si además quieres que el ancestro pueda cancelar la acción, añade cancelable: true y comprueba el valor que devuelve dispatchEvent, que será false si algún listener llamó a preventDefault().
Burbujeo y Shadow DOM en Web Components
Los Web Components introducen una capa adicional: el Shadow DOM, un árbol encapsulado dentro de un elemento. Cuando un evento se origina dentro de un shadow root, su comportamiento depende de la opción composed.
Los eventos nativos de interacción, como click, son compuestos y cruzan la frontera del Shadow DOM. Pero al salir, el navegador aplica un retargeting: para los listeners de fuera, el target deja de ser el elemento interno y pasa a ser el elemento anfitrión del componente. Así se preserva la encapsulación.
class BotonFavorito extends HTMLElement {
constructor() {
super();
const raiz = this.attachShadow({ mode: "open" });
raiz.innerHTML = `<button class="interno">❤ Favorito</button>`;
}
}
customElements.define("boton-favorito", BotonFavorito);
document.addEventListener("click", (evento) => {
console.log("target visto desde fuera:", evento.target.tagName);
console.log("camino real:", evento.composedPath()[0].className);
});
target visto desde fuera: BOTON-FAVORITO
camino real: interno
El método composedPath() devuelve la ruta completa del evento, incluidos los nodos internos de shadow roots abiertos, y es la herramienta adecuada si necesitas saber exactamente dónde se originó.
Para eventos personalizados, la regla es más estricta: si los despachas desde dentro del shadow root y quieres que salgan del componente, debes indicar composed: true además de bubbles: true.
this.shadowRoot
.querySelector(".interno")
.dispatchEvent(
new CustomEvent("favorito-cambiado", { bubbles: true, composed: true })
);
Cómo lo gestionan los frameworks
Si trabajas con un framework, el burbujeo sigue presente, aunque a veces con matices. React, por ejemplo, no registra un listener nativo en cada elemento. Desde React 17, adjunta sus listeners al contenedor raíz de la aplicación y usa la delegación internamente para despachar sus eventos sintéticos a los componentes. El burbujeo de los eventos de React sigue el árbol de componentes, por lo que funciona de forma similar al del DOM.
function Tarjeta() {
return (
<article onClick={() => console.log("tarjeta")}>
<button
onClick={(evento) => {
evento.stopPropagation();
console.log("botón");
}}
>
Añadir
</button>
</article>
);
}
Hay un caso que sorprende a muchos desarrolladores: los portales de React. Un componente renderizado con un portal vive en otra parte del DOM, pero sus eventos burbujean por el árbol de componentes de React, no por el del DOM. Un clic dentro de un modal montado en un portal llegará al onClick de sus componentes padre aunque visualmente esté fuera de ellos.
Vue ofrece modificadores declarativos que encapsulan estas técnicas, como .stop para detener la propagación, .prevent para cancelar la acción por defecto, .self para reaccionar solo cuando el elemento es el propio target y .capture para escuchar en captura:
<div @click.self="cerrarModal">
<form @submit.prevent="guardar">
<button @click.stop="duplicar">Duplicar</button>
</form>
</div>
El modificador .self es especialmente útil para el caso clásico del fondo de un modal, que veremos a continuación en versión nativa.
Casos de uso habituales
El burbujeo aparece en patrones de interfaz muy comunes. Estos son algunos donde conviene aplicar lo aprendido.
Cerrar un modal al hacer clic en el fondo, pero no en su contenido. En lugar de detener la propagación en el contenido, basta con comprobar si el clic se originó en el propio fondo:
const fondo = document.querySelector(".modal-fondo");
fondo.addEventListener("click", (evento) => {
if (evento.target === evento.currentTarget) {
fondo.hidden = true;
}
});
Si usas el elemento nativo <dialog> con showModal(), la misma comprobación funciona: un clic en el fondo tiene como target al propio diálogo.
Cerrar un menú desplegable al hacer clic fuera de él. Un listener en el documento comprueba si el clic ocurrió fuera del menú:
const menu = document.querySelector(".menu-usuario");
document.addEventListener("click", (evento) => {
if (!menu.contains(evento.target)) {
menu.classList.remove("abierto");
}
});
Este patrón depende de que el clic burbujee hasta el documento. Si algún componente intermedio llama a stopPropagation(), el menú nunca se cerrará, uno de los efectos secundarios más típicos de abusar de ese método.
Tablas con miles de filas. Delegar los clics en el elemento tbody evita registrar miles de listeners y permite paginar o reordenar filas sin volver a enlazar eventos.
Errores comunes y cómo evitarlos
Hay tropiezos que se repiten en proyectos de todos los tamaños.
El más frecuente es usar stopPropagation() como solución rápida. Funciona en el momento, pero rompe cualquier lógica que dependa de recibir ese evento más arriba: menús que se cierran al hacer clic fuera, herramientas de analítica, atajos globales. Antes de cortar la propagación, intenta resolver el problema en el ancestro comprobando target, currentTarget o defaultPrevented.
El segundo es comparar evento.target directamente con el elemento esperado en delegación. Si el botón contiene un icono, el clic sobre el icono tendrá ese icono como target, y la comparación fallará. Usa siempre closest.
El tercero es intentar delegar eventos que no burbujean, como focus o mouseenter, y concluir que el listener no funciona. Cambia a sus equivalentes que burbujean o escucha en captura.
El cuarto es olvidar la opción de captura al quitar un listener, lo que deja el listener activo para siempre.
El quinto es leer currentTarget dentro de una operación asíncrona, cuando ya vale null.
El sexto es registrar el mismo listener varias veces al volver a renderizar una parte de la página. Con funciones anónimas, removeEventListener no puede encontrarlas porque cada una es una referencia distinta:
// No elimina nada: son dos funciones diferentes
boton.addEventListener("click", () => guardar());
boton.removeEventListener("click", () => guardar());
Buenas prácticas para producción
Para mantener el manejo de eventos limpio y predecible en aplicaciones reales, conviene adoptar algunas costumbres.
Prefiere la delegación en contenedores con muchos hijos o con contenido dinámico, y listeners directos en elementos únicos y estables, como un botón de envío.
Usa AbortController para eliminar varios listeners de una sola vez. La opción signal de addEventListener permite desconectarlos todos llamando a abort(), sin guardar referencias a cada función. Es especialmente útil al desmontar componentes:
const controlador = new AbortController();
const { signal } = controlador;
document.addEventListener("click", cerrarMenus, { signal });
window.addEventListener("resize", recalcularLayout, { signal });
lista.addEventListener("click", manejarTareas, { signal });
// Al destruir la vista
controlador.abort();
Aprovecha la opción once para listeners que solo deben ejecutarse una vez, como una animación de bienvenida, y passive: true para eventos de desplazamiento o táctiles en los que no vas a llamar a preventDefault(). Esta última permite al navegador iniciar el scroll sin esperar a tu código, lo que mejora la fluidez:
window.addEventListener("scroll", actualizarBarraProgreso, { passive: true });
botonBienvenida.addEventListener("click", mostrarTour, { once: true });
Usa atributos data-* para identificar acciones en la delegación en lugar de depender de clases de estilo. Así, un cambio de diseño no romperá la lógica.
Documenta cualquier uso de stopPropagation() con un comentario que explique por qué es necesario, para que el resto del equipo entienda que ese corte es intencional.
Depurar la propagación desde el navegador
Las herramientas de desarrollo de los navegadores ofrecen ayudas específicas para entender qué listeners existen y cómo se recorren.
En el panel de elementos de Chrome, Edge y otros navegadores basados en Chromium hay una pestaña de listeners de eventos que muestra todos los registrados en el nodo seleccionado y sus ancestros, agrupados por tipo. Firefox muestra un distintivo “event” junto a los nodos con listeners. Desde la consola de los navegadores basados en Chromium dispones además de utilidades como getEventListeners, que devuelve los listeners de un elemento, y monitorEvents, que registra en consola los eventos que recibe:
getEventListeners(document.querySelector("#tareas"));
monitorEvents(document.querySelector(".tarjeta"), "click");
Estas utilidades solo existen en la consola de las herramientas de desarrollo, no en el código de tu aplicación. Para depurar desde el propio código, un listener de captura en window que imprima evento.composedPath() te mostrará el recorrido completo de cualquier evento:
window.addEventListener(
"click",
(evento) => {
const ruta = evento
.composedPath()
.map((n) => n.className || n.nodeName || "window");
console.log(ruta.join(" → "));
},
{ capture: true }
);
agregar → pie → tarjeta → BODY → HTML → #document → window
Conclusiones
El event bubbling es el mecanismo por el que un evento, después de llegar al elemento donde se originó, sube por todos sus ancestros hasta la raíz del documento. Junto con las fases de captura y de destino, forma el recorrido completo que siguen los eventos del DOM, y entenderlo explica comportamientos que de otro modo parecerían caprichosos.
A lo largo del artículo hemos visto cómo observar ese recorrido, cómo distinguir el elemento de origen con target del elemento actual con currentTarget, cómo escuchar en la fase de captura y cómo cortar la propagación con stopPropagation() o stopImmediatePropagation(). También hemos separado claramente ese control del recorrido de la cancelación de acciones por defecto con preventDefault().
La lección más valiosa es que el burbujeo no es un problema que haya que combatir, sino una herramienta. La delegación de eventos aprovecha precisamente ese recorrido para gestionar listas, tablas y contenido dinámico con un único listener, con menos memoria y menos código. Combinada con closest, atributos data-*, eventos personalizados que burbujean y la opción signal de AbortController, te permite construir interfaces más simples y robustas, tanto en JavaScript nativo como en cualquier framework moderno.
