Módulo 01 // Unidad 04

Propagación y delegación de eventos

Por qué un clic en un elemento también llega a sus antepasados, y cómo aprovecharlo

Unidad para alumnos del curso

Comprobando tu sesión…

Inicia sesión con la cuenta de Google con la que estás matriculado para ver esta unidad.

Esa cuenta no tiene acceso a este curso. Prueba con otra.

No se ha podido cargar el inicio de sesión de Google. Comprueba la conexión o si algún bloqueador lo impide, y recarga la página.

En la unidad anterior registraste listeners en elementos concretos. Pero un clic sobre un <li> también es un clic sobre la lista que lo contiene, sobre el <body> y sobre el documento entero. El navegador lo resuelve haciendo viajar el evento por el árbol, y ese viaje permite una técnica muy útil: atender los eventos de muchos elementos con un solo listener.

1 // Las tres fases de un evento

Cuando ocurre un evento sobre un elemento, el navegador no avisa sólo a ese elemento. Lo hace recorrer el árbol DOM en tres fases:

  1. Captura: el evento baja desde window pasando por cada antepasado hasta llegar al elemento.
  2. Destino: el evento llega al elemento donde se originó, el target.
  3. Burbuja: el evento sube de vuelta desde el elemento hasta window, pasando otra vez por cada antepasado.
EL VIAJE DE UN EVENTO POR EL ÁRBOL 1 · captura: baja desde window window document body ul#lista li · target 2 · destino 3 · burbuja: sube hasta window (la fase que usa addEventListener por defecto) un único listener en ul#lista atiende el clic de cualquier li, también de los añadidos después
Sigue la flecha inferior: después de llegar al li, el evento sube y pasa por la lista. Por eso un listener colocado en ul#lista se entera de los clics en cualquiera de sus elementos.

En cada paso, el navegador ejecuta los listeners que haya registrados en ese elemento para ese tipo de evento. Por defecto, addEventListener escucha en la fase de burbuja. Si pasas la opción { capture: true }, escucha en la de captura, algo que rara vez necesitarás.

lista.addEventListener('click', () => console.log('ul'));
item.addEventListener('click', () => console.log('li'));

// Al hacer clic en el li se muestra: 'li' y después 'ul'

La mayoría de eventos suben por burbuja, pero no todos: focus y blur no lo hacen. Si necesitas algo equivalente que suba, existen focusin y focusout.

2 // target y currentTarget

Como un mismo evento pasa por varios elementos, el objeto evento distingue dos cosas:

Propiedad Qué es ¿Cambia durante el viaje?
e.target El elemento donde se originó el evento No
e.currentTarget El elemento cuyo listener se está ejecutando Sí, en cada paso

Si el <li> contiene un <span> y el usuario pulsa justo sobre el texto del span, e.target será el span, no el li. Este detalle es la causa de muchos errores y es la razón por la que se usa closest, como verás enseguida.

3 // Detener la propagación

El método e.stopPropagation() detiene el viaje del evento: los listeners de los elementos siguientes en el recorrido no se ejecutarán.

botonInterior.addEventListener('click', (e) => {
  e.stopPropagation();   // la tarjeta que lo contiene no se entera del clic
  borrarTarjeta();
});

No confundas stopPropagation() con preventDefault(). El primero corta el viaje del evento por el árbol; el segundo cancela la acción por defecto del navegador (enviar, navegar). Son independientes: puedes usar uno, el otro o los dos. Y usa stopPropagation con moderación, porque todo lo que escuche más arriba dejará de recibir el evento.

4 // Delegación de eventos

Imagina una lista de cien tareas, cada una con un botón «Borrar». Podrías registrar cien listeners, pero hay dos problemas: consumen memoria y, sobre todo, las tareas que añadas después no tendrán listener, porque se crearon cuando el registro ya se había hecho.

La delegación aprovecha la burbuja: se registra un único listener en un contenedor que siempre existe y, dentro, se averigua qué elemento concreto originó el clic.

const lista = document.querySelector('#lista');

lista.addEventListener('click', (e) => {
  const boton = e.target.closest('.borrar');
  if (!boton) return;                 // el clic no fue en un botón de borrar

  const item = boton.closest('li');
  item.remove();
});

El patrón tiene siempre la misma forma:

  1. Un listener en el contenedor.
  2. e.target.closest(selector) para encontrar el elemento que te interesa, aunque el clic haya caído en un icono o un texto de dentro.
  3. Si no hay coincidencia, salir sin hacer nada.
  4. Si la hay, actuar sobre él, normalmente usando sus atributos data-* para saber a qué dato corresponde.

Usa delegación siempre que los elementos se creen o destruyan dinámicamente, o cuando haya muchos elementos iguales. Un único listener en el contenedor funciona para los elementos actuales y para todos los que se añadan en el futuro.

Una lista de tareas se pinta desde un array con la función pintar de la unidad anterior. Cada li tiene data-id y dentro un botón con la clase completar. Al pulsarlo, la tarea debe marcarse como hecha en los datos y la lista debe volver a pintarse.

lista.addEventListener('click', (e) => {
  const boton = e.target.closest('.completar');
  if (!boton) return;

  const id = Number(boton.closest('li').dataset.id);
  const tarea = tareas.find(t => t.id === id);
  tarea.hecha = !tarea.hecha;

  pintar(tareas);
});
  1. Un listener, registrado una vez: está en #lista, que no se sustituye nunca; pintar sólo cambia sus hijos.
  2. Identificar el botón: closest('.completar') funciona tanto si el clic cae en el botón como en un icono de dentro.
  3. Pasar del DOM a los datos: dataset.id es texto, así que se convierte con Number para compararlo con ===.
  4. Cambiar los datos y repintar: no se toca la clase del li a mano; se actualiza el array y se vuelve a generar la lista.
Los botones funcionan aunque pintar destruya y recree todos los li en cada cambio, porque el listener no está en ellos sino en su contenedor. Con listeners individuales, tras el primer repintado ningún botón respondería.

5 // Para practicar

Observa el viaje de un evento por la consola y aplica la delegación a una lista que se repinta entera. Cada ejercicio trae el enunciado en los comentarios del código.

Fases de captura y burbujatraining-daw-sv/modulo-01/04-propagacion-y-delegacion/01_event_phasesLista de tareas con delegacióntraining-daw-sv/modulo-01/04-propagacion-y-delegacion/02_task_delegation

Autoevaluación

Tienes un listener de click en un <ul> y haces clic en un <li> de dentro. ¿Qué valen e.target y e.currentTarget en ese listener?
e.target es el <li> pulsado y e.currentTarget, el <ul>. target es donde se originó el evento y no cambia durante todo el recorrido. currentTarget es el elemento cuyo listener se está ejecutando en ese momento, que aquí es la lista.
Añades con JavaScript nuevas filas a una tabla, y los botones «Borrar» de las filas nuevas no funcionan, mientras que los de las filas iniciales sí. ¿Qué ha pasado y cómo lo resuelve la delegación?
Los listeners se registraron sobre los botones que existían al cargar, y los nuevos nunca recibieron uno. Con delegación, el único listener está en la tabla, que siempre existe. Como los clics suben por burbuja, cualquier botón, antiguo o nuevo, acaba llegando a ese listener.
¿Por qué no es buena idea usar stopPropagation() para evitar que un clic dentro de un menú lo cierre?
Porque corta el evento para todos los listeners superiores, no sólo para el que te molesta. Otros componentes, la delegación o una analítica que escuchen en document dejan de enterarse. Suele ser mejor que el listener que cierra el menú compruebe con closest si el clic fue dentro.