Tema
Eventos
Un clic en "Agregar al carrito", una tecla presionada, un formulario enviado, el mouse que pasa sobre una imagen: todo eso son eventos. Escucharlos y reaccionar a ellos es lo que hace interactiva una página. En esta lección aprenderás a manejarlos como un profesional, incluida la técnica de delegación, imprescindible en listas dinámicas.
Concepto
Imagina un timbre: tú no te quedas parado junto a la puerta todo el día; instalas un timbre y cuando alguien lo toca, vas a abrir. En JavaScript:
- El evento es que alguien toque el timbre (
click). - El listener (escuchador) es el timbre instalado en un elemento.
- El handler (manejador) es lo que haces cuando suena: una función.
js
const boton = document.querySelector('#comprar');
boton.addEventListener('click', () => {
console.log('¡Clic en comprar!');
});addEventListener
js
elemento.addEventListener(tipoDeEvento, funcion, opciones);js
const boton = document.querySelector('#saludar');
// Con función con nombre (reutilizable y removible)
function saludar() {
alert('¡Hola!');
}
boton.addEventListener('click', saludar); // ¡sin paréntesis!
// Con función flecha
boton.addEventListener('click', () => console.log('Otro listener'));Error común
boton.addEventListener('click', saludar()) ejecuta saludar inmediatamente y registra su resultado (undefined) como listener. Pasa la función, no su llamada: saludar sin paréntesis.
¿Y onclick?
También existe boton.onclick = saludar y el atributo HTML onclick="saludar()". Evítalos: sólo permiten un manejador por evento y mezclan HTML con JavaScript. addEventListener es el estándar profesional.
El objeto event
El navegador le pasa a tu función un objeto con toda la información del evento:
js
document.addEventListener('click', (event) => {
console.log(event.type); // 'click'
console.log(event.target); // el elemento exacto donde se hizo clic
console.log(event.currentTarget); // el elemento que tiene el listener
console.log(event.clientX, event.clientY); // posición del mouse
});
document.addEventListener('keydown', (e) => {
console.log(e.key); // 'a', 'Enter', 'Escape', 'ArrowUp'...
console.log(e.code); // 'KeyA' (tecla física)
console.log(e.ctrlKey, e.shiftKey, e.altKey, e.metaKey);
// Atajo Ctrl + K (o Cmd + K en Mac)
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
e.preventDefault();
abrirBuscador();
}
});Eventos más usados
| Evento | Cuándo ocurre |
|---|---|
click, dblclick | Clic o doble clic |
mouseenter, mouseleave | El mouse entra o sale de un elemento |
keydown, keyup | Se presiona o suelta una tecla |
input | El valor de un campo cambia (en cada tecla) |
change | El valor cambia y el campo pierde el foco (o en select/checkbox) |
submit | Se envía un formulario |
focus, blur | Un campo gana o pierde el foco |
scroll | Se hace scroll |
resize | Cambia el tamaño de la ventana |
DOMContentLoaded | El HTML terminó de cargarse |
load | Cargó todo (imágenes incluidas) |
js
// input: búsqueda en vivo
const buscador = document.querySelector('#buscar');
buscador.addEventListener('input', (e) => {
console.log('Buscando:', e.target.value);
});
// change: select
document.querySelector('#pais').addEventListener('change', (e) => {
console.log('País elegido:', e.target.value);
});
// checkbox
document.querySelector('#acepto').addEventListener('change', (e) => {
botonEnviar.disabled = !e.target.checked;
});preventDefault: cancelar el comportamiento por defecto
Algunos elementos tienen acciones automáticas: un enlace navega, un formulario recarga la página al enviarse. preventDefault() las cancela.
js
const form = document.querySelector('#contacto');
form.addEventListener('submit', (e) => {
e.preventDefault(); // evita que la página se recargue
const datos = new FormData(form);
console.log(Object.fromEntries(datos));
});
// Enlace que abre un modal en lugar de navegar
document.querySelector('#ver-terminos').addEventListener('click', (e) => {
e.preventDefault();
abrirModal();
});Propagación: bubbling y capturing
Cuando haces clic en un botón dentro de una tarjeta dentro de una sección, el clic ocurre en los tres. El evento viaja en tres fases:
- Captura: baja desde
documenthasta el elemento. - Objetivo: llega al elemento donde ocurrió.
- Burbujeo (bubbling): sube de regreso hasta
document.
Por defecto, los listeners se ejecutan en la fase de burbujeo: primero el del botón, luego el de la tarjeta, luego el de la sección.
html
<section id="seccion">
<div id="tarjeta">
<button id="boton">Clic</button>
</div>
</section>js
const log = (nombre) => () => console.log(nombre);
document.querySelector('#seccion').addEventListener('click', log('sección'));
document.querySelector('#tarjeta').addEventListener('click', log('tarjeta'));
document.querySelector('#boton').addEventListener('click', log('botón'));
// Al hacer clic en el botón: botón → tarjeta → sección
// Escuchar en fase de captura (se ejecuta ANTES, de afuera hacia adentro)
document.querySelector('#seccion').addEventListener('click', log('sección (captura)'), { capture: true });Para detener la propagación usa e.stopPropagation(). Úsalo con moderación: romper el burbujeo puede hacer que otros componentes (menús que se cierran al hacer clic fuera, analíticas) dejen de funcionar.
Delegación de eventos
Supón una lista de tareas donde cada una tiene un botón "Eliminar". Podrías poner un listener en cada botón... pero:
- Si tienes 500 tareas, son 500 listeners.
- Las tareas nuevas que agregues no tendrán listener.
La delegación aprovecha el burbujeo: pones un solo listener en el contenedor y averiguas con event.target dónde se hizo clic.
html
<ul id="tareas">
<li data-id="1">Diseñar logo <button class="eliminar">✕</button></li>
<li data-id="2">Maquetar <button class="eliminar">✕</button></li>
</ul>js
const lista = document.querySelector('#tareas');
lista.addEventListener('click', (e) => {
// closest sube desde el elemento clicado hasta encontrar el botón
// (funciona aunque el clic sea en un ícono dentro del botón)
const btn = e.target.closest('.eliminar');
if (!btn) return; // el clic no fue en un botón eliminar
const li = btn.closest('li');
console.log('Eliminar tarea', li.dataset.id);
li.remove();
});
// Las tareas nuevas funcionan automáticamente, sin listeners extra
lista.insertAdjacentHTML('beforeend',
'<li data-id="3">Programar <button class="eliminar">✕</button></li>');Con varias acciones, usa un atributo data-accion:
js
lista.addEventListener('click', (e) => {
const btn = e.target.closest('[data-accion]');
if (!btn) return;
const id = Number(btn.closest('li').dataset.id);
const acciones = {
completar: () => completarTarea(id),
editar: () => editarTarea(id),
eliminar: () => eliminarTarea(id)
};
acciones[btn.dataset.accion]?.();
});Buena práctica
Usa delegación siempre que tengas listas generadas dinámicamente (resultados de búsqueda, carritos, tablas). Es más eficiente y evita errores de "los elementos nuevos no responden".
Quitar listeners y opciones útiles
js
function manejarScroll() { /* ... */ }
window.addEventListener('scroll', manejarScroll, { passive: true });
window.removeEventListener('scroll', manejarScroll); // misma función exacta
// once: se ejecuta una sola vez y se elimina solo
boton.addEventListener('click', () => console.log('Sólo una vez'), { once: true });
// AbortController: quitar varios listeners de golpe
const controlador = new AbortController();
window.addEventListener('resize', alRedimensionar, { signal: controlador.signal });
window.addEventListener('keydown', alPresionar, { signal: controlador.signal });
controlador.abort(); // elimina ambosError común
removeEventListener necesita la misma referencia de función. Esto no funciona:
js
btn.addEventListener('click', () => hacerAlgo());
btn.removeEventListener('click', () => hacerAlgo()); // es OTRA funciónGuarda la función en una constante o usa AbortController.
Componentes reales con eventos
Modal accesible
Un modal debe cerrarse con su botón, al hacer clic en el fondo oscuro y con la tecla Escape:
js
const modal = document.querySelector('#modal');
const abrir = document.querySelector('#abrir-modal');
function abrirModal() {
modal.classList.add('visible');
modal.querySelector('button').focus(); // accesibilidad: mover el foco
document.addEventListener('keydown', cerrarConEscape);
}
function cerrarModal() {
modal.classList.remove('visible');
abrir.focus(); // devolver el foco
document.removeEventListener('keydown', cerrarConEscape);
}
function cerrarConEscape(e) {
if (e.key === 'Escape') cerrarModal();
}
abrir.addEventListener('click', abrirModal);
modal.addEventListener('click', (e) => {
// si el clic fue en el fondo (no dentro de la caja), cerrar
if (e.target === modal || e.target.closest('[data-cerrar]')) cerrarModal();
});Alternativa nativa
HTML ya trae la etiqueta <dialog> con showModal(), close() y cierre con Escape incluido. Para proyectos nuevos es una gran opción.
Pestañas (tabs)
js
const tabs = document.querySelectorAll('[role="tab"]');
const paneles = document.querySelectorAll('[role="tabpanel"]');
tabs.forEach((tab) => {
tab.addEventListener('click', () => {
tabs.forEach((t) => t.setAttribute('aria-selected', 'false'));
paneles.forEach((p) => (p.hidden = true));
tab.setAttribute('aria-selected', 'true');
document.getElementById(tab.getAttribute('aria-controls')).hidden = false;
});
});Optimizar eventos frecuentes: debounce
input, scroll y resize se disparan decenas de veces por segundo. Con debounce esperas a que el usuario deje de escribir antes de actuar (por ejemplo, para no llamar a una API en cada tecla):
js
function debounce(fn, espera = 300) {
let temporizador;
return (...args) => {
clearTimeout(temporizador);
temporizador = setTimeout(() => fn(...args), espera);
};
}
buscador.addEventListener('input', debounce((e) => {
buscarEnApi(e.target.value);
}, 400));Ejercicios
- Crea un botón que cambie el color de fondo de la página a uno aleatorio en cada clic.
- Muestra en un
<p>cuántos caracteres lleva un<textarea>mientras escribes, con un máximo de 140; pon el texto en rojo si se pasa. - Con delegación, haz que al hacer clic en cualquier
<li>de una lista se marque/desmarque con la claseseleccionado. - Haz que al presionar la tecla
/el foco vaya a un input de búsqueda (sin escribir la barra). - Crea un menú desplegable que se cierre al hacer clic fuera de él.
Ver solución
js
// 1
document.querySelector('#color').addEventListener('click', () => {
const hue = Math.floor(Math.random() * 360);
document.body.style.backgroundColor = `hsl(${hue} 70% 85%)`;
});
// 2
const area = document.querySelector('#mensaje');
const contador = document.querySelector('#contador');
area.addEventListener('input', () => {
const n = area.value.length;
contador.textContent = `${n}/140`;
contador.style.color = n > 140 ? 'red' : '';
});
// 3
document.querySelector('#lista').addEventListener('click', (e) => {
const li = e.target.closest('li');
if (li) li.classList.toggle('seleccionado');
});
// 4
document.addEventListener('keydown', (e) => {
const escribiendo = ['INPUT', 'TEXTAREA'].includes(document.activeElement.tagName);
if (e.key === '/' && !escribiendo) {
e.preventDefault();
document.querySelector('#buscar').focus();
}
});
// 5
const menu = document.querySelector('.menu');
const toggle = document.querySelector('.menu-toggle');
toggle.addEventListener('click', () => menu.classList.toggle('abierto'));
document.addEventListener('click', (e) => {
if (!menu.contains(e.target) && !toggle.contains(e.target)) {
menu.classList.remove('abierto');
}
});Resumen
addEventListener(tipo, funcion)es la forma estándar de escuchar eventos; pasa la función sin paréntesis.- El objeto
eventtraetarget,key, coordenadas y más. preventDefault()cancela acciones por defecto (envío de formularios, navegación).- Los eventos burbujean del elemento hacia sus ancestros.
- Delegación: un listener en el contenedor +
e.target.closest(...)para listas dinámicas. - Usa
once,removeEventListeneroAbortControllerpara limpiar, ydebouncepara eventos frecuentes.
Siguiente: Formularios y validación