Eventos
En resumen: Los eventos avisan a JavaScript de que alguien hizo clic o escribió: addEventListener escucha, casi todos los eventos suben por el árbol, y la delegación usa un solo listener para muchos elementos.
Problema
El producto nuevo no se añade
El catálogo muestra dos productos. «Mostrar más» añade un tercero. «Añadir» en los dos primeros sube el carrito, pero «Añadir» en el producto nuevo no hace nada. La consola no muestra ningún error.
Consola
Haz clic en «Añadir» en el pan, luego en «Mostrar más» y luego en «Añadir» en el queso. El carrito se queda en 1. El botón del queso se ve igual que los demás y tiene el mismo texto. ¿Qué le falta?
La idea
Evento y listener
Cuando la compradora hace clic, pulsa una tecla o escribe en un campo, el navegador crea un evento: un objeto que dice qué pasó y dónde. El código lo espera con un listener (un oyente):
const button = document.querySelector('#plus');button.addEventListener('click', (event) => { console.log(event.type, event.target);});- El primer argumento es el tipo de evento:
click,keydown,input,submit. - El segundo es una función. El navegador la llama en cada evento y le pasa el objeto del evento. Las funciones flecha vienen de la lección sobre lambdas del curso de JavaScript.
- Pasas la función, no la llamas:
addEventListener('click', save), sin paréntesis.save()la llamaría enseguida.
El objeto del evento tiene campos:
event.targetes el elemento donde ocurrió el evento: el botón en el que se hizo clic.event.currentTargetes el elemento cuyo listener se está ejecutando ahora.event.keyes la tecla enkeydown:'Enter','Escape','a'.
Ahora está claro qué falta en el problema. Los listeners se ponen una vez, al cargar la página, en los botones que existen en ese momento. El botón del queso se crea más tarde y no tiene listener.
Pruébalo
Más y menos
Haz los botones de cantidad: «+» aumenta la cantidad en 1 y «−» la reduce en 1, pero no por debajo de 1. La cantidad se muestra en #qty.
Consola
Solución
index.html<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8">
<title>Pan Dobrudzha</title>
<link rel="stylesheet" href="style.css">
<script src="script.js" defer></script>
</head>
<body>
<h1>Pan Dobrudzha</h1>
<p>
<button type="button" id="minus" aria-label="Menos">−</button>
<output id="qty">1</output>
<button type="button" id="plus" aria-label="Más">+</button>
</p>
</body>
</html>
style.cssbody { font-family: system-ui, sans-serif; padding: 16px; }
button { font-size: 16px; padding: 6px 14px; }
output { display: inline-block; min-width: 2em; text-align: center; font-size: 20px; }
script.jsconst qty = document.querySelector('#qty');
let count = 1;
document.querySelector('#plus').addEventListener('click', () => {
count++;
qty.textContent = count;
});
document.querySelector('#minus').addEventListener('click', () => {
if (count > 1) count--;
qty.textContent = count;
});
Cada botón tiene su propio listener con una función que cambia count y lo muestra con textContent. En «−», la comprobación if (count > 1) evita que llegue a 0. Si la función está aparte, la pasas sin paréntesis: addEventListener('click', plus). Con plus() se llama una vez mientras la página carga, la cantidad pasa a 2 y no hay listener.
La idea
El evento sube
El botón está dentro de <li>, <li> está dentro de <ul>, y <ul> está dentro de <main>. Un clic en el botón es también un clic en cada uno de ellos. Así que el evento no se queda en el botón. Empieza en event.target y sube (bubbling) por los padres hasta document y window. Cada padre con un listener de click lo oye.
document.querySelector('ul').addEventListener('click', (event) => { console.log(event.target); // el botón en el que se hizo clic console.log(event.currentTarget); // <ul>: el elemento con el listener});Casi todos los eventos suben: click, keydown, input, submit. Algunos no: focus y blur. Para ellos existen focusin y focusout, que sí suben.
Míralo
Mira cómo sube el clic
A la izquierda está el árbol del catálogo. El listener está en <ul>, y el clic es en el botón de la leche.
const list = document.querySelector('#products');list.addEventListener('click', (event) => { const button = event.target.closest('button'); if (!button) return; const item = button.closest('li'); console.log('Añadir', item.dataset.id);});Paso a paso, en palabras
- Hay un solo listener, y está en la lista <ul>, no en los botones. Espera click.
- La compradora hace clic en «Añadir» de la leche. El evento empieza en el elemento más interno bajo el cursor: el botón. Ese es event.target.
- Después sube: del botón a su padre <li>, luego a <ul>. Cada padre también oye el clic.
- <ul> tiene un listener y la función arranca. event.currentTarget es <ul>, el elemento con el listener. event.target sigue siendo el botón.
- closest('button') empieza en el propio event.target y sube. El destino es un botón, así que se encuentra enseguida.
- closest('li') sube desde el botón y se detiene en el primer <li>: la leche. Su data-id dice de qué producto se trata.
- dataset.id es "2". El carrito sabe qué tiene que añadir.
- El evento sigue subiendo hasta <main>, <body>, document y window. Ahí no hay listeners y no pasa nada más.
- Un clic en el nombre del pan también sube hasta <ul>. Pero el destino es <h2>: closest('button') no encuentra ningún botón por encima y devuelve null, y la función se detiene.
- Un solo listener atiende todos los botones de la lista, también los que se añaden más tarde: también están dentro de <ul>. Esto se llama delegación.
Un clic en el nombre del pan también llega a <ul>. Por eso el listener comprueba primero que se haya hecho clic en un botón.
La idea
Delegación
En lugar de un listener por botón, pones uno en el padre común. Él averigua con event.target en qué botón se hizo clic. Esto se llama delegación.
list.addEventListener('click', (event) => { const button = event.target.closest('button'); if (!button) return; const id = button.closest('li').dataset.id; // …añadir el producto id});closest('button')empieza en el propio elemento y sube hasta el primero que coincide con el selector. Si no hay ninguno, devuelvenull. Aquí protege de un clic en el nombre o en el espacio vacío del<li>.- Los datos del producto están en el
<li>(data-id), no en el botón.closest('li')llega hasta ellos.
Hay dos ventajas. Los productos nuevos funcionan sin hacer nada más: también están en la lista. Y hay un solo listener, sean cuantos sean los productos.
Pruébalo
Arregla el catálogo
Arregla el catálogo con un solo listener en #products. «Añadir» en cada producto, incluido el que se añade con «Mostrar más», sube el carrito y escribe «producto añadido» y el data-id del producto en la consola. Un clic en el nombre de un producto no añade nada.
Consola
Solución
index.html<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8">
<title>Catálogo</title>
<link rel="stylesheet" href="style.css">
<script src="script.js" defer></script>
</head>
<body>
<p id="count">Carrito: 0</p>
<ul id="products">
<li data-id="1">
<h2>Pan Dobrudzha</h2>
<button type="button">Añadir</button>
</li>
<li data-id="2">
<h2>Leche fresca 3 %</h2>
<button type="button">Añadir</button>
</li>
</ul>
<button type="button" id="more">Mostrar más</button>
</body>
</html>
style.cssbody { font-family: system-ui, sans-serif; padding: 16px; }
button { font-size: 16px; padding: 6px 14px; }
#products { list-style: none; padding: 0; }
#products li { display: flex; align-items: center; gap: 12px; border-bottom: 1px solid #ddd; }
#products h2 { font-size: 18px; flex: 1; }
script.jsconst count = document.querySelector('#count');
let items = 0;
document.querySelector('#products').addEventListener('click', (event) => {
const button = event.target.closest('button');
if (!button) return;
items++;
count.textContent = `Carrito: ${items}`;
console.log('producto añadido', button.closest('li').dataset.id);
});
document.querySelector('#more').addEventListener('click', () => {
const li = document.createElement('li');
li.dataset.id = '3';
const title = document.createElement('h2');
title.textContent = 'Queso';
const button = document.createElement('button');
button.type = 'button';
button.textContent = 'Añadir';
li.append(title, button);
document.querySelector('#products').append(li);
});
El listener está en #products, así que también oye el botón del queso. event.target.closest('button') es null cuando el clic es en <h2>, y la función termina. event.target.dataset.id es undefined: data-id está en el <li>, así que es button.closest('li').dataset.id.
La idea
La acción por defecto
Algunos eventos tienen una acción que el navegador hace por sí mismo, después de los listeners. Un clic en un enlace abre su dirección. submit en un formulario envía los campos y carga la página nueva. Esta es la acción por defecto (default action).
El formulario de búsqueda de aquí muestra el resultado en la página. Pruébalo.
Consola
El listener escribe «Buscas: leche», pero después el navegador envía el formulario. En un navegador real la página se carga otra vez con ?q=leche en la dirección, y el resultado desaparece. El sandbox es una sola página: impide el envío y escribe «El formulario se envió» en la consola.
Añade event.preventDefault(); al principio de la función y ejecútala de nuevo. La línea de la consola desaparece: la acción está detenida, y el listener sigue haciendo su trabajo. Lo mismo vale para un enlace que abre algo dentro de la página en lugar de ir a otra dirección.
event.stopPropagation() detiene la subida. Rara vez lo necesitas, y casi siempre es mala señal. También detiene listeners que el código no conoce: la delegación de arriba, cerrar un menú al hacer clic fuera, contar clics. Si un listener «oye demasiado», comprueba event.target, como hace closest en la delegación.
Pruébalo
Encuentra el error
El listener muestra el resultado, pero el formulario se envía igualmente.
Elige la línea con el error.
event.preventDefault sin paréntesis solo lee el método y no lo llama. Debería ser event.preventDefault();.
La idea
input o change
Los campos tienen dos eventos para un cambio:
inputllega con cada cambio del valor: cada letra, cada borrado, cada pegado.changellega cuando la compradora termina. En un campo de texto, es cuando sale de él. En una casilla, un botón de opción y un menú desplegable, es justo después de elegir.
Para una búsqueda que filtra mientras escribes, usa input. Para un menú desplegable «Ordenar por precio», usa change.
Más precisamente, para curiosos
Un tercer argumento. addEventListener('click', f, { once: true }) escucha solo una vez. removeEventListener('click', f) quita el listener, pero solo si pasas la misma función. Una función flecha escrita otra vez es otra función distinta.
onclick. button.onclick = f también funciona, pero es un único campo: un segundo onclick borra el primero. addEventListener permite muchos listeners. El atributo onclick="…" en HTML mezcla el código con la página, y una política de seguridad estricta (CSP) lo bloquea.
Propagación y captura. El recorrido del evento tiene también otra dirección: primero de window hacia abajo hasta el destino (captura), luego hacia arriba (bubbling). addEventListener('click', f, { capture: true }) escucha en el camino de bajada. Casi siempre quieres el bubbling.
El teclado. Un <button> recibe click también con Enter y con la barra espaciadora. Por eso click en un botón funciona con el teclado. keydown en un <div> no lo sustituye (mira la lección sobre accesibilidad).
event.key frente a event.code. key es el carácter ('a' en un teclado QWERTY, 'q' para la misma tecla en un AZERTY francés), y code es dónde está la tecla ('KeyA' en ambos). Para atajos como Escape y Enter, usa key.
Ejercicio
Búsqueda en vivo
Haz que la búsqueda filtre la lista mientras la compradora escribe: solo se muestran los productos que contienen el texto, sean mayúsculas o minúsculas. Un producto oculto tiene hidden. #found muestra cuántos se ven («Encontrados: 2»). Escape en el campo lo vacía y muestra todos. Enter no envía el formulario.
Consola
Solución
index.html<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8">
<title>Catálogo</title>
<link rel="stylesheet" href="style.css">
<script src="script.js" defer></script>
</head>
<body>
<form id="search">
<label>Búsqueda <input id="q" autocomplete="off"></label>
</form>
<p id="found">Encontrados: 4</p>
<ul id="products">
<li>Pan Dobrudzha</li>
<li>Leche fresca 3 %</li>
<li>Leche con chocolate</li>
<li>Queso</li>
</ul>
</body>
</html>
style.cssbody { font-family: system-ui, sans-serif; padding: 16px; }
button { font-size: 16px; padding: 6px 14px; }
script.jsconst input = document.querySelector('#q');
const found = document.querySelector('#found');
const items = document.querySelectorAll('#products li');
function filter() {
const q = input.value.trim().toLowerCase();
let shown = 0;
items.forEach((li) => {
const match = li.textContent.toLowerCase().includes(q);
li.hidden = !match;
if (match) shown++;
});
found.textContent = `Encontrados: ${shown}`;
}
input.addEventListener('input', filter);
input.addEventListener('keydown', (event) => {
if (event.key === 'Escape') {
input.value = '';
filter();
}
});
document.querySelector('#search').addEventListener('submit', (event) => {
event.preventDefault();
});
La función filter recorre cada <li>, compara su texto con la búsqueda (los dos con toLowerCase()) y pone li.hidden. Se ejecuta con input: en cada letra. Con change la lista no cambia mientras la compradora escribe. Escape es keydown con event.key === 'Escape': un campo vacío y otra vez filter(). La solución también detiene Enter con preventDefault() en submit, para que no se envíe el formulario. La comprobación pulsa Enter y mira la consola: el sandbox no vuelve a cargar la página, pero escribe cuando se envía el formulario.
Test
Ponte a prueba
Lecciones relacionadas
Sección «Lecciones relacionadas»- Se basa en: Texto, enlaces, imágenes, tablas · Formularios · Selectores y la cascada · El DOM: la página desde JavaScript
- Continúa en: Una lista a partir de datos · Datos desde una API · El primer componente · Listas y claves
- Ejercicios sobre el tema: JavaScript en la página