Aprende leyendo en orden

Eventos — addEventListener y propagación

addEventListener y event.target para responder a clics, delegación de eventos con bubbling, stopPropagation, y cómo quitar listeners con once y removeEventListener.

Asignar a textContent cambia el elemento una sola vez, en el momento en que se ejecuta esa línea. Si quieres sumar 1 a un contador cada vez que alguien pulsa un botón, no te bastan las líneas que se ejecutan de arriba abajo, porque el código no sabe cuándo va a llegar el clic.

En este artículo verás addEventListener, que llama a una función cuando ocurre un clic, y cómo responder desde el elemento padre a los clics en sus hijos.

Llamar a una función al hacer clic — addEventListener

En la página de un producto, cada clic en el botón «Agregar al carrito» debe sumar 1 al número de productos del carrito y mostrarlo. Si escribes sin más la línea que actualiza el número, se ejecuta una sola vez, al cargar la página, y los clics posteriores en el botón no lo cambian.

Para que se llame a una función cuando ocurre un evento (algo que pasa en la página, como un clic o una entrada de texto), la registras pasando el nombre del evento y la función, como en addEventListener("click", función). A la función registrada se le llama event listener (o simplemente listener). En la consola no puedes hacer clic con el mouse, así que provocas los clics con el método click, como en cartButton.click().

document.body.innerHTML = `<button id="add-to-cart">Agregar al carrito</button><p>Carrito: <span id="cart-count">0</span> productos</p>`;
const cartButton = document.querySelector("#add-to-cart");
const cartCount = document.querySelector("#cart-count");

// La función que se llama al hacer clic (aquí solo se define)
function addToCart() {
  cartCount.textContent = Number(cartCount.textContent) + 1;
}

// Pasa la propia función para registrarla (en esta línea no se llama a addToCart)
cartButton.addEventListener("click", addToCart);
console.log(cartCount.textContent);   // 0

// Cada vez que provocas un clic, se llama al addToCart registrado
cartButton.click();
cartButton.click();
console.log(cartCount.textContent);   // 2
Las líneas en las que se ejecuta addToCart
addEventListener("click", addToCart)cartButton.click()(1.º)cartButton.click()(2.º)Solo registra;sigue en 0Llama a addToCart;el número pasa a 1Se llama otra vez;pasa a 2
La línea del registro no llama a addToCart, así que el número sigue en 0. El cuerpo de la función se ejecuta en las líneas donde ocurre un clic.

En una página real no escribes click(): el navegador llama a addToCart en cuanto el usuario pulsa el botón. Solo responde a los clics que ocurren después del registro; los que se hicieron antes de la línea del registro no cuentan.

Con () se ejecuta una sola vez, en la línea del registro

Si escribes addEventListener("click", addToCart()), addToCart se llama una vez en la línea del registro, y lo que se pasa es su valor de retorno, undefined. Como no aparece ningún error, cuesta darse cuenta de por qué el número no sube al hacer clic. Al registrar, pasa solo el nombre de la función, sin ().

En la página de un autor, cambia lo que muestra el botón de seguir cada vez que se hace clic en él. El botón followButton ya está declarado.

① Registra una función que, en cada clic, alterne la clase active y cambie el texto entre «Seguir» y «Siguiendo».

② Provoca un clic y muestra el HTML del botón, incluida su etiqueta.

③ Provoca otro clic y muestra el HTML del botón.

(Si se ejecuta correctamente, aparecerá una explicación.)

Editor JavaScript / TypeScript

Ejecutar el código para ver el resultado

Recibir en el padre el clic de un hijo — bubbling y delegación

Ahora quieres leer el número de pedido de la fila que se pulsa en una lista de pedidos. Si reúnes las filas con querySelectorAll y registras un listener en cada una con forEach, solo tienen listener las filas que ya estaban en la página en ese momento, así que, si haces clic en una fila que agregaste después con append, no pasa nada.

Gracias al bubbling (el mecanismo por el que un evento que ocurre en un elemento pasa a su padre, luego al padre de ese padre, y así sucesivamente), un listener registrado en el ul padre también se llama cuando se hace clic en un li. El navegador le pasa al listener un objeto de evento (un objeto con la información del evento que ocurrió) como argumento. En el código de abajo, el argumento se llama event, y con su target lees el elemento donde se originó el clic.

document.body.innerHTML = `<section id="order-panel"><ul id="order-list"><li data-id="A-101">Pedido A-101</li><li data-id="A-102">Pedido A-102</li></ul></section>`;
const orderList = document.querySelector("#order-list");
const orderPanel = document.querySelector("#order-panel");

// Regístralo en el ul padre y en el section que lo rodea, no en los li
orderList.addEventListener("click", (event) => {
  console.log(`Recibido en ul: ${event.target.dataset.id}`);
});
orderPanel.addEventListener("click", (event) => {
  console.log(`Recibido en section: ${event.target.dataset.id}`);
});

// Provoca un clic en el li A-102
document.querySelector('[data-id="A-102"]').click();
// Recibido en ul: A-102
// Recibido en section: A-102
El recorrido del clic en A-102
section: su listener se llama después
  • event.target.dataset.id es A-102
ul: su listener se llama primero
  • event.target.dataset.id es A-102
li A-101: no se hizo clic
  • El clic no llega a los li hermanos
li A-102: donde se llamó a click()
  • event.target es este li
  • Desde aquí el clic empieza a subir
El clic pasa del li al ul y luego al section, y nunca pasa por el li hermano. event.target es el li A-102 en todos los casos.

Registrar un solo listener en el padre y usar event.target para saber en qué hijo se hizo clic se llama delegación de eventos. El clic de un li se recibe después de subir hasta el ul, así que el mismo listener se llama también para un li que agregaste con append después del registro. event.target es el elemento más interno en el que se hizo clic, así que, si el li contiene un span, apunta al span.

En la pantalla de reserva de salas de reuniones, detecta en qué horario de la lista se hace clic. El elemento ul con la lista de horarios, slotList, ya está declarado.

① Registra un solo listener en la lista que muestre la hora del horario pulsado y le agregue la clase selected.

② Provoca un clic en el horario de las 10:00.

③ Crea un horario de las 16:00, agrégalo al final de la lista y provoca un clic en él.

④ Muestra el HTML de la lista, incluida la etiqueta ul.

Editor JavaScript / TypeScript

Ejecutar el código para ver el resultado

Impedir que el clic llegue al padre — stopPropagation

Piensa en una fila de pedido que abre su detalle al hacer clic y que contiene un botón «Eliminar» para borrar el pedido. El botón es hijo de la fila, así que el clic en «Eliminar» también sube hasta la fila por bubbling, y además de eliminar el pedido, se abre su detalle.

Que un evento pase a los elementos exteriores se llama propagación. stopPropagation (un método que detiene la propagación en el elemento cuyo listener se está ejecutando) se usa escribiendo event.stopPropagation() dentro del listener del botón. Una vez detenida, los listeners registrados en elementos exteriores, como la fila o la lista, no se llaman.

document.body.innerHTML = `<ul><li id="order-row">Pedido A-201 <button id="favorite">Favorito</button><button id="delete">Eliminar</button></li></ul>`;
const orderRow = document.querySelector("#order-row");
const favoriteButton = document.querySelector("#favorite");
const deleteButton = document.querySelector("#delete");

// Al hacer clic en la fila, se abre el detalle
orderRow.addEventListener("click", () => console.log("Abrir detalle"));

// Favorito: también puede abrir el detalle, así que no lo detiene y el clic llega a la fila
favoriteButton.addEventListener("click", () => console.log("Agregado a favoritos"));
favoriteButton.click();   // Agregado a favoritos, Abrir detalle (2 líneas)

// Eliminar: llama a stopPropagation() para que el clic no llegue a la fila
deleteButton.addEventListener("click", (event) => {
  event.stopPropagation();
  console.log("Pedido eliminado");
});
deleteButton.click();     // Pedido eliminado
Un clic que se propaga y uno que se detiene
favoriteButton.click()Muestra «Agregadoa favoritos»No se detiene:llega a la filaTambién se muestra«Abrir detalle»deleteButton.click()stopPropagation();luego lo eliminaNo llega al lide la fila«Abrir detalle»no se muestra
Favorito no detiene el clic, porque no importa que se abra el detalle, y el clic llega a la fila. Si llamas a stopPropagation(), el listener de la fila no se llama.

En el listener de eliminar, «Pedido eliminado» se muestra aunque esté escrito después de stopPropagation(). Lo que se detiene es el paso del evento a los elementos exteriores, no el resto del listener en el que se llamó.

Un clic detenido tampoco llega al ul que delega

stopPropagation() impide que el evento llegue no solo a la fila que rodea el botón, sino a todos los elementos que hay más afuera. Aunque el ul de la lista tenga un listener delegado que registra las acciones del usuario, los clics en el botón de eliminar no quedan registrados. Detén solo los clics que ningún elemento exterior necesita recibir.

En la lista de notificaciones de un chat interno, al hacer clic en una fila se abre la notificación, y el botón de silenciar que está dentro de la fila solo la silencia. La fila de la notificación, noticeRow, ya está declarada y tiene un listener registrado.

① Registra un listener en el botón de silenciar que muestre «Notificación silenciada» y evite que el clic llegue a la fila.

② Provoca un clic en el botón de silenciar.

③ Muestra el HTML de la fila, incluida la etiqueta li.

④ Provoca un clic en la propia fila.

Editor JavaScript / TypeScript

Ejecutar el código para ver el resultado

Quitar un listener — once y removeEventListener

Si un usuario pulsa dos veces seguidas el botón «Confirmar pedido», el listener registrado se llama dos veces y el mismo pedido se envía dos veces. Una vez aceptado el primer envío, tienes que lograr que el listener no se llame aunque vuelvan a pulsar el mismo botón.

Si pasas { once: true } como tercer argumento de addEventListener, el listener se quita solo después de llamarse una vez. Para quitarlo en el momento que quieras, llama a removeEventListener (un método que recibe un nombre de evento y una función, busca entre los listeners registrados la función que sea === a esa y la quita).

document.body.innerHTML = `<button id="confirm">Confirmar pedido</button>`;
const confirmButton = document.querySelector("#confirm");
const sendOrder = () => console.log("Pedido enviado");

// { once: true }: se quita solo después de llamarse una vez
confirmButton.addEventListener("click", sendOrder, { once: true });
confirmButton.click();   // Pedido enviado
confirmButton.click();   // (no se muestra nada)

// Si pasas el sendOrder registrado, se quita ese listener
confirmButton.addEventListener("click", sendOrder);
confirmButton.removeEventListener("click", sendOrder);
confirmButton.click();   // (no se muestra nada)

// Una arrow function escrita de nuevo es otra función, aunque tenga el mismo cuerpo, así que no se quita nada
confirmButton.addEventListener("click", () => console.log("Pedido enviado"));
confirmButton.removeEventListener("click", () => console.log("Pedido enviado"));
confirmButton.click();   // Pedido enviado
¿Se llama en el siguiente clic después de quitarlo?
confirmButton:3 formas de quitarRegistrar con{ once: true }removeEventListener("click", sendOrder)removeEventListener("click", () => …)Se quita solotras una llamadaEs la mismafunción: se quita=== da false:no se quitaNo se llama enel siguiente clicNo se llama enel siguiente clicSigue mostrando«Pedido enviado»
Con once, o al pasar la misma función que registraste, el listener se quita. Si pasas una arrow function escrita de nuevo, el listener no se quita.

() => … crea una función nueva cada vez que la escribes, así que, aunque el cuerpo sea igual, al compararla con === con la función registrada da false. Si vas a quitar un listener más adelante, guarda antes la función en una variable, como sendOrder, y registra esa variable. La tabla de abajo resume las formas de trabajar con eventos que viste en este artículo.

CódigoQué haceQué tener en cuenta
addEventListener("click", fn)Llama a fn en cada clicPasa fn sin ()
event.targetEl elemento pulsadoEl más interno, aun en el padre
Uno en el padre (delegación)Recibe los clics de los hijosTambién hijos agregados después
event.stopPropagation()Detiene el paso hacia afueraNi el padre que delega lo recibe
{ once: true }Lo quita tras una llamadaVa en el 3.er argumento
removeEventListener("click", fn)Quita el listenerPasa la misma función

Esta es la pantalla de inscripción a un webinar. registerButton, waitButton, attendeeCount y waitingCount ya están declarados.

① Registra en el botón de inscripción un listener que sume 1 a los inscritos una sola vez.

② Provoca dos clics en el botón de inscripción y muestra el número de inscritos.

③ Registra una función que sume 1 persona a la lista de espera y provoca un clic.

④ Quita la función de ③, provoca otro clic y muestra el número de la lista de espera.

Editor JavaScript / TypeScript

Ejecutar el código para ver el resultado
QUIZ

Verificación de conocimientos

Responde cada pregunta una a una.

Pregunta 1Con saveButton.addEventListener("click", save());, ¿cuándo se llama a save?

Pregunta 2Dentro de un listener registrado en un ul, ¿qué es event.target cuando llamas a click() sobre un li de ese ul?

Pregunta 3Después de registrar () => send(), ¿qué pasa si llamas a removeEventListener("click", () => send())?