Eventos & Event Handling

Los eventos son el corazón de la interactividad en la web. Cada vez que un usuario hace click, escribe en un teclado, mueve el mouse, hace scroll o envía un formulario, el navegador dispara un evento. JavaScript te permite "escuchar" esos eventos y ejecutar código en respuesta. Entender cómo funcionan los eventos, su flujo de propagación y las técnicas para manejarlos eficientemente es lo que separa un sitio estático de uno verdaderamente interactivo.

Experimentá con los 5 conceptos clave de eventos en este playground interactivo: bubbling & capturing con flujo visual, event delegation vs listeners individuales, custom events con canales y listeners, keyboard events con el event object en tiempo real, y throttle & debounce con visualización de frecuencia.

Events Playground

addEventListener y removeEventListener

addEventListener es el método estándar y moderno para registrar event handlers. A diferencia de las propiedades onclick, onmouseover, etc., permite agregar múltiples listeners al mismo evento en el mismo elemento sin que se sobreescriban entre sí. Además, ofrece control sobre la fase de captura y la posibilidad de remover el listener más adelante. Es la forma correcta y recomendada de manejar eventos en JavaScript moderno.

JavaScript
// === Sintaxis basica ===
elemento.addEventListener(evento, handler, opciones);

// === Ejemplo simple ===
const boton = document.querySelector("#mi-boton");

boton.addEventListener("click", () => {
    console.log("Boton clickeado!");
});

// Con funcion nombrada (mejor para remover despues)
function handleClick(e) {
    console.log("Click en:", e.target);
}

boton.addEventListener("click", handleClick);

// Multiples listeners en el mismo elemento y evento
boton.addEventListener("click", () => console.log("Listener 1"));
boton.addEventListener("click", () => console.log("Listener 2"));
// Ambos se ejecutan al hacer click

// === Opciones (tercer parametro) ===
boton.addEventListener("click", handleClick, {
    capture: false,    // escuchar en fase de captura (default: false)
    once: true,        // ejecutar solo una vez y auto-remover
    passive: true      // no va a llamar a preventDefault (mejor performance)
});

// === NO uses propiedades on* (limitan a un solo handler) ===
// MAL:
// boton.onclick = () => console.log("uno");
// boton.onclick = () => console.log("dos"); // sobreescribe el anterior

// BIEN:
// boton.addEventListener("click", () => console.log("uno"));
// boton.addEventListener("click", () => console.log("dos")); // ambos funcionan

// === removeEventListener ===
// Para remover, necesitas la MISMA referencia de funcion
boton.removeEventListener("click", handleClick);

// Arrow functions NO se pueden remover asi:
const handler = () => console.log("no removable");
boton.addEventListener("click", handler);
boton.removeEventListener("click", handler); // funciona con la referencia

// Pero esto NO funciona:
boton.addEventListener("click", () => console.log("anonima"));
// No hay forma de remover una arrow function anonima

El tercer parámetro de addEventListener acepta tanto un booleano (que equivale a capture) como un objeto de opciones. La opción once: true es muy útil para eventos que solo necesitás manejar una vez, como la carga inicial de la página o un mensaje de bienvenida. La opción passive: true le dice al navegador que tu handler no va a llamar a preventDefault(), lo que permite optimizar el scroll y otros eventos de alta frecuencia (de hecho, los eventos touchstart y wheel son passive por defecto en navegadores modernos).

Funciones nombradas vs anónimas

Usá funciones nombradas (o variables que apunten a funciones) cuando necesites remover el listener después. Las arrow functions anónimas son prácticas para listeners permanentes, pero imposibles de remover con removeEventListener porque no tenés una referencia a ellas. Además, las funciones nombradas mejoran la legibilidad del código porque el nombre aparece en el stack trace cuando depurás.

Tipos de eventos

El navegador dispara cientos de tipos de eventos distintos. Los más comunes se agrupan en categorías: eventos de mouse (click, dblclick, mouseenter, mouseleave, mousemove, mousedown, mouseup), eventos de teclado (keydown, keyup, keypress), eventos de formulario (submit, change, input, focus, blur), eventos de ventana (load, resize, scroll, error) y eventos táctiles (touchstart, touchmove, touchend). Elegir el evento correcto es fundamental para que tu interfaz responda de forma natural y precisa.

JavaScript
// === Mouse Events ===

// click — click completo (mousedown + mouseup)
boton.addEventListener("click", (e) => {
    console.log("Click en:", e.target);
});

// dblclick — doble click rapido
boton.addEventListener("dblclick", () => {
    console.log("Doble click!");
});

// mouseenter / mouseleave — entran/salen del elemento (NO bubblean)
card.addEventListener("mouseenter", () => card.classList.add("hover"));
card.addEventListener("mouseleave", () => card.classList.remove("hover"));

// mouseover / mouseout — entran/salen (SI bubblean, includes children)
card.addEventListener("mouseover", (e) => {
    console.log("Mouse sobre:", e.target);
});

// === Input / Form Events ===

// input — se dispara en CADA cambio (incluyendo pegar, arrastrar)
inputTexto.addEventListener("input", (e) => {
    console.log("Valor actual:", e.target.value);
});

// change — se dispara al perder el foco si el valor cambio
selectColor.addEventListener("change", (e) => {
    console.log("Color seleccionado:", e.target.value);
});

// submit — al enviar un formulario
formulario.addEventListener("submit", (e) => {
    e.preventDefault(); // evitar recarga de pagina
    const datos = new FormData(formulario);
    console.log("Nombre:", datos.get("nombre"));
});

// focus / blur — cuando un elemento gana/pierde foco
inputTexto.addEventListener("focus", () => {
    inputTexto.parentElement.classList.add("focused");
});
inputTexto.addEventListener("blur", () => {
    inputTexto.parentElement.classList.remove("focused");
});

// === Window Events ===

// load — cuando la pagina termino de cargar (HTML, CSS, imagenes)
window.addEventListener("load", () => {
    console.log("Todo cargado, incluidas imagenes");
});

// DOMContentLoaded — cuando el DOM esta listo (sin esperar imagenes)
document.addEventListener("DOMContentLoaded", () => {
    console.log("DOM listo, se puede manipular");
});

// resize — cuando se cambia el tamano de la ventana
window.addEventListener("resize", () => {
    console.log("Ancho:", window.innerWidth);
});

// scroll — al hacer scroll
window.addEventListener("scroll", () => {
    console.log("Scroll Y:", window.scrollY);
});

// error — cuando un recurso falla al cargar
img.addEventListener("error", (e) => {
    e.target.src = "placeholder.png";
    console.log("Error cargando imagen");
});

input vs change vs keypress

Usá input para responder en tiempo real a cada cambio del usuario (búsquedas en vivo, contadores de caracteres). Usá change cuando solo te interesa el valor final al perder el foco (selección de un <select> o checkbox). Y evitá keypress porque está deprecado: usá keydown o input en su lugar.

El objeto Event

Cada vez que se dispara un evento, el navegador crea un objeto Event que contiene información detallada sobre lo que pasó. Este objeto se pasa automáticamente como parámetro a tu handler. Las propiedades más importantes son target (el elemento donde se originó el evento), currentTarget (el elemento que está escuchando el evento), type (el nombre del evento), y los métodos preventDefault() y stopPropagation() que controlan el comportamiento por defecto y la propagación del evento respectivamente.

JavaScript
// === Propiedades principales del Event ===

const lista = document.querySelector("ul");

lista.addEventListener("click", (event) => {
    // event.type — nombre del evento
    console.log(event.type);           // "click"

    // event.target — elemento DONDE se origino el click (el li, el span, etc.)
    console.log(event.target);         // el elemento exacto clickeado

    // event.currentTarget — elemento que tiene el listener (la ul)
    console.log(event.currentTarget);  // la <ul>

    // event.timeStamp — milisegundos desde que la pagina cargo
    console.log(event.timeStamp);

    // event.isTrusted — true si el evento fue generado por el usuario (no por JS)
    console.log(event.isTrusted);      // true para clicks reales
});

// === preventDefault() — cancelar el comportamiento por defecto ===

// Evitar que un link navegue a otra pagina
document.querySelector("a.sin-navegacion").addEventListener("click", (e) => {
    e.preventDefault();
    console.log("Link clickeado pero no navega");
});

// Evitar que un formulario recargue la pagina
document.querySelector("form").addEventListener("submit", (e) => {
    e.preventDefault();
    // procesar datos con JS...
});

// Evitar el menu contextual (click derecho)
document.addEventListener("contextmenu", (e) => {
    e.preventDefault();
});

// === target vs currentTarget (diferencia CLAVE para delegation) ===

// Imagina esta estructura:
// <ul>
//   <li><span>Item 1</span></li>
//   <li><span>Item 2</span></li>
// </ul>

lista.addEventListener("click", (e) => {
    // Si haces click en el <span> "Item 1":
    // e.target = <span>Item 1</span>  (el elemento que clickeaste)
    // e.currentTarget = <ul>              (donde esta el listener)

    // Usar closest para encontrar el li
    const li = e.target.closest("li");
    if (li) {
        console.log("Clickeaste en:", li.textContent);
    }
});

La diferencia entre target y currentTarget es uno de los conceptos más importantes en el manejo de eventos. event.target es siempre el elemento más profundo donde se originó la acción (por ejemplo, un <span> dentro de un <button>), mientras que event.currentTarget es siempre el elemento al que le attachaste el listener. Cuando el evento no bubblea (o usas delegación simple), suelen ser el mismo. Pero cuando hay elementos anidados, target puede ser un hijo y currentTarget el padre.

preventDefault no detiene la propagación

preventDefault() solo cancela la acción por defecto del navegador (navegar al hacer click en un link, enviar el formulario, etc.) pero no detiene la propagación del evento por el DOM. Para eso necesitás stopPropagation(). Son dos cosas distintas que a menudo se confunden.

Bubbling y Capturing

Cuando se dispara un evento en un elemento, ese evento no se queda ahí. Viaja por el DOM en tres fases: primero desciende desde la raíz hasta el target (fase de captura), luego llega al elemento objetivo (fase target), y finalmente asciende de vuelta hacia la raíz (fase de bubbling). La mayoría de los eventos usan bubbling por defecto, lo que significa que un click en un botón también dispara los listeners de click en todos sus ancestros. Entender este flujo es esencial para event delegation y para evitar bugs inesperados.

HTML
<!-- Estructura de ejemplo para demostrar el flujo -->
<div class="abuelo">
    <div class="padre">
        <button class="hijo">Click me</button>
    </div>
</div>
JavaScript
// === Las 3 fases del evento ===
// 1. Capturing: document → abuelo → padre → hijo (desciende)
// 2. Target: llega al hijo (el elemento clickeado)
// 3. Bubbling: hijo → padre → abuelo → document (asciende)

// === BUBBLING (default) — el evento sube desde el target ===
document.querySelector(".abuelo").addEventListener("click", (e) => {
    console.log("1. Abuelo (bubbling)");
}, false); // capture: false es el default

document.querySelector(".padre").addEventListener("click", (e) => {
    console.log("2. Padre (bubbling)");
}, false);

document.querySelector(".hijo").addEventListener("click", (e) => {
    console.log("3. Hijo (bubbling)");
}, false);
// Al hacer click en .hijo: "3. Hijo" → "2. Padre" → "1. Abuelo"

// === CAPTURING — el evento baja desde la raiz ===
document.querySelector(".abuelo").addEventListener("click", (e) => {
    console.log("A. Abuelo (capturing)");
}, true); // capture: true

document.querySelector(".padre").addEventListener("click", (e) => {
    console.log("B. Padre (capturing)");
}, true);

document.querySelector(".hijo").addEventListener("click", (e) => {
    console.log("C. Hijo (capturing)");
}, true);
// Al hacer click en .hijo: "A. Abuelo" → "B. Padre" → "C. Hijo"

// === Flujo COMPLETO (mezcla de ambos) ===
// Orden real al hacer click en .hijo:
// 1. Abuelo capturing
// 2. Padre capturing
// 3. Hijo (target phase)
// 4. Padre bubbling
// 5. Abuelo bubbling

// === stopPropagation() — detener la propagacion ===
document.querySelector(".padre").addEventListener("click", (e) => {
    console.log("Padre");
    e.stopPropagation(); // el evento no llega al abuelo
});

// === stopImmediatePropagation() — detener TODO (incluye otros listeners) ===
document.querySelector(".hijo").addEventListener("click", (e) => {
    console.log("Listener 1 del hijo");
    e.stopImmediatePropagation(); // ningun otro listener se ejecuta
});
document.querySelector(".hijo").addEventListener("click", (e) => {
    console.log("Listener 2 del hijo"); // NUNCA se ejecuta
});

En la práctica, casi siempre trabajás con bubbling (la fase por defecto). La fase de captura se usa en casos muy específicos, como cuando necesitás interceptar un evento antes de que llegue a un elemento hijo. La mayoría de los eventos bubblean, pero hay excepciones notables: focus, blur, mouseenter, mouseleave y load NO bubblean. Para esos casos, existe su versión que sí bubblea: focusin/focusout y mouseover/mouseout.

Evitá stopPropagation en lo posible

Detener la propagación con stopPropagation() puede causar efectos secundarios difíciles de rastrear, especialmente en aplicaciones complejas donde otros componentes también necesitan escuchar el mismo evento. Es mucho mejor usar event.target y condiciones para filtrar si te interesa o no el evento, en vez de cortar la propagación completamente.

Event Delegation

Event delegation es una técnica que aprovecha el bubbling para manejar eventos de muchos elementos con un solo listener colocado en un ancestro común. En vez de poner un addEventListener en cada botón de una lista, ponés un listener en el <ul> padre y verificás con event.target y closest() qué elemento fue el que realmente recibió el click. Esto es más eficiente en memoria, funciona automáticamente con elementos agregados dinámicamente, y simplifica tu código significativamente.

JavaScript
// === El PROBLEMA: un listener por cada item ===

// MAL — ineficiente, no funciona con elementos nuevos
const items = document.querySelectorAll(".lista-item");
items.forEach(item => {
    item.addEventListener("click", () => {
        item.classList.toggle("selected");
    });
});

// Si agregas un item nuevo al DOM, no tiene listener!
const nuevo = document.createElement("li");
nuevo.textContent = "Nuevo item";
nuevo.className = "lista-item";
document.querySelector("ul").appendChild(nuevo);
// click en nuevo = nada pasa

// === La SOLUCION: event delegation ===

// BIEN — un solo listener en el padre
document.querySelector("ul").addEventListener("click", (e) => {
    // Encontrar el item mas cercano al click
    const item = e.target.closest(".lista-item");
    if (!item) return; // si no se clickeo un item, ignorar

    item.classList.toggle("selected");
    console.log("Clickeaste:", item.textContent);
});

// Ahora los items nuevos TAMBIEN funcionan automaticamente!
// Porque el listener esta en la ul, no en cada li.

// === Ejemplo practico: tabla con botones de eliminar ===

document.querySelector("table").addEventListener("click", (e) => {
    const btn = e.target.closest(".btn-delete");
    if (!btn) return;

    const fila = btn.closest("tr");
    const nombre = fila?.querySelector("td").textContent;

    if (confirm(`Eliminar a ${nombre}?`)) {
        fila?.remove();
    }
});

// === Ejemplo: navegacion con links ===

document.querySelector("nav").addEventListener("click", (e) => {
    const link = e.target.closest("a");
    if (!link) return;

    // Cerrar menu mobile si esta abierto
    document.querySelector(".sidebar")?.classList.remove("open");

    // Si es link interno, prevenir navegacion
    if (link.getAttribute("href").startsWith("#")) {
        e.preventDefault();
        const seccion = document.querySelector(link.getAttribute("href"));
        seccion?.scrollIntoView({ behavior: "smooth" });
    }
});

// === Ejemplo: multiple buttons con data attributes ===

document.querySelector(".toolbar").addEventListener("click", (e) => {
    const btn = e.target.closest("button");
    if (!btn) return;

    const accion = btn.dataset.action;
    switch (accion) {
        case "bold":
            document.execCommand("bold");
            break;
        case "italic":
            document.execCommand("italic");
            break;
        case "underline":
            document.execCommand("underline");
            break;
        case "save":
            guardarContenido();
            break;
    }
});

Siempre usá closest() con delegation

El patrón gold standard de event delegation es: const el = e.target.closest('.mi-clase'); if (!el) return;. Esto maneja correctamente los casos donde el usuario clickea un hijo del elemento que te interesa (como un icono dentro de un botón), y también ignora clicks en áreas vacías del contenedor. Es robusto, simple y funciona con elementos dinámicos.

Custom Events

Además de los eventos nativos del navegador, JavaScript te permite crear tus propios eventos personalizados con new CustomEvent(). Esto es útil para crear sistemas de comunicación entre módulos de tu aplicación que no necesitan conocerse directamente. Por ejemplo, un módulo de carrito de compras puede disparar un evento "cart-updated" cuando se agrega un producto, y un módulo de notificaciones puede escuchar ese evento para mostrar un badge actualizado, sin que ninguno de los dos se referencie directamente.

JavaScript
// === Crear y disparar un custom event ===

// Crear el evento con datos personalizados
const eventoLogin = new CustomEvent("usuario:login", {
    detail: {
        userId: 42,
        nombre: "Carlos",
        rol: "admin",
        timestamp: Date.now()
    },
    bubbles: true,    // permite que el evento bubble
    cancelable: true  // permite preventDefault()
});

// Disparar el evento en un elemento
document.dispatchEvent(eventoLogin);

// === Escuchar el custom event ===
document.addEventListener("usuario:login", (e) => {
    console.log("Usuario logueado:", e.detail.nombre);
    console.log("Rol:", e.detail.rol);
    console.log("ID:", e.detail.userId);
});

// === Patron practico: comunicacion entre modulos ===

// Modulo de carrito — dispara eventos
const Carrito = {
    items: [],

    agregar(producto) {
        this.items.push(producto);

        // Disparar evento para que otros modulos reaccionen
        const evento = new CustomEvent("carrito:actualizado", {
            detail: {
                items: this.items,
                total: this.items.reduce((sum, p) => sum + p.precio, 0),
                cantidad: this.items.length
            }
        });
        document.dispatchEvent(evento);
    }
};

// Modulo de UI — reacciona al evento
document.addEventListener("carrito:actualizado", (e) => {
    const { cantidad, total } = e.detail;
    const badge = document.querySelector(".cart-badge");
    badge.textContent = cantidad;
    badge.classList.toggle("hidden", cantidad === 0);
});

// Modulo de analytics — tambien reacciona
document.addEventListener("carrito:actualizado", (e) => {
    analytics.track("carrito_update", {
        cantidad: e.detail.cantidad,
        total: e.detail.total
    });
});

// === Usar el patron de naming con prefijo ===
// "modulo:accion" para evitar colisiones con eventos nativos
// Ejemplos: "usuario:logout", "toast:show", "modal:open"

// === Tambien podes usar Event (sin datos) para eventos simples ===
const eventoSimple = new Event("datos:limpiados");
document.dispatchEvent(eventoSimple);

La convención de nombrar custom events con el patrón "modulo:accion" (por ejemplo "carrito:actualizado", "usuario:login", "modal:cerrar") es una buena práctica que evita colisiones con eventos nativos del navegador y hace fácil identificar el origen y propósito del evento. Los datos siempre van en la propiedad detail del objeto event, y cualquier listener puede accederlos via e.detail. Este patrón es la base de arquitecturas desacopladas en JavaScript vanilla.

Keyboard Events

Los eventos de teclado son esenciales para accesibilidad, atajos de teclado, validación en tiempo real y juegos. Los tres eventos principales son keydown (se presiona una tecla), keyup (se suelta una tecla) y keypress (deprecado, no usar). El objeto event de teclado tiene propiedades específicas: key (el caracter o nombre de la tecla), code (el código físico de la tecla en el teclado), y event.ctrlKey, event.shiftKey, event.altKey, event.metaKey para las teclas modificadoras.

JavaScript
// === key vs code ===

document.addEventListener("keydown", (e) => {
    // key — el caracter resultante (depende del layout del teclado)
    console.log(e.key);    // "a", "A", "Enter", "Escape", "1", "!", etc.

    // code — la tecla fisica (NO depende del layout)
    console.log(e.code);   // "KeyA", "Enter", "Escape", "Digit1", etc.
});

// Diferencia clave:
// Si tenes teclado QWERTY y presionas la tecla 'q':
//   e.key = "q"     (el caracter)
//   e.code = "KeyQ" (la tecla fisica, siempre la misma)

// Si cambiás a teclado AZERTY y presionas la misma tecla fisica:
//   e.key = "a"     (porque en AZERTY esa tecla produce 'a')
//   e.code = "KeyQ" (la tecla fisica no cambia)

// Regla: usá e.key para texto, e.code para atajos de teclado

// === Modifiers (teclas especiales) ===

document.addEventListener("keydown", (e) => {
    if (e.ctrlKey && e.key === "s") {
        e.preventDefault(); // evitar guardar la pagina
        console.log("Guardar archivo (Ctrl+S)");
    }

    if (e.ctrlKey && e.shiftKey && e.key === "I") {
        e.preventDefault();
        console.log("Abrir inspector (Ctrl+Shift+I)");
    }

    if (e.key === "Escape") {
        cerrarModal();
    }
});

// === Ejemplo: atajos de teclado para una app ===

document.addEventListener("keydown", (e) => {
    // Ctrl+K o Cmd+K — abrir buscador
    if ((e.ctrlKey || e.metaKey) && e.key === "k") {
        e.preventDefault();
        abrirBuscador();
    }

    // Ctrl+/ — toggle comments (en un editor)
    if ((e.ctrlKey || e.metaKey) && e.key === "/") {
        e.preventDefault();
        toggleComments();
    }

    // Solo teclas numericas para un input de telefono
});

telefonoInput.addEventListener("keydown", (e) => {
    // Permitir: numeros, backspace, delete, tab, escape, enter, flechas
    const permitidas = ["Backspace", "Delete", "Tab", "Escape", "Enter",
                        "ArrowLeft", "ArrowRight", "Home", "End"];
    if (permitidas.includes(e.key)) return;

    // Permitir Ctrl+C, Ctrl+V, Ctrl+A, etc.
    if (e.ctrlKey || e.metaKey) return;

    // Solo permitir numeros y signos de telefono
    if (!/^[0-9+\-() ]$/.test(e.key)) {
        e.preventDefault();
    }
});

// === Ejemplo: campo de busqueda con Escape para cerrar ===

const buscador = document.querySelector("#search");
const resultados = document.querySelector("#search-results");

buscador.addEventListener("keydown", (e) => {
    if (e.key === "Escape") {
        buscador.value = "";
        resultados.classList.add("hidden");
        buscador.blur();
    }
    if (e.key === "ArrowDown") {
        e.preventDefault();
        // Mover foco al primer resultado
        resultados.querySelector("a")?.focus();
    }
});

key para texto, code para atajos

Usá e.key cuando te interesa el valor que el usuario escribió (como validar que un campo solo contenga letras). Usá e.code cuando implementás atajos de teclado físicos que deben funcionar sin importar el layout del teclado del usuario (por ejemplo, "KeyW" siempre es la tecla W física, mientras que e.key sería "w" en QWERTY o "z" en AZERTY).

Throttle y Debounce

Algunos eventos se disparan cientos de veces por segundo: scroll, resize, mousemove y input son los culpables más comunes. Si ejecutás lógica pesada (cálculos, manipulaciones del DOM, peticiones de red) en cada uno de esos disparos, vas a tener una aplicación lenta y con mala performance. Throttle y debounce son dos técnicas que limitan la frecuencia de ejecución de una función para mantener la fluidez de la interfaz.

Debounce espera un período de inactividad antes de ejecutar la función. Si el evento se sigue disparando, reinicia el timer. Es ideal para búsquedas en vivo: no querés hacer una petición por cada tecla, sino esperar a que el usuario deje de escribir. Throttle ejecuta la función a intervalos regulares, como máximo una vez cada X milisegundos. Es ideal para scroll y resize: querés actualizar la UI durante el evento, pero no en cada frame.

JavaScript
// === DEBOUNCE ===
// Espera Nms de inactividad antes de ejecutar
// Si el evento se repite antes, reinicia el timer

function debounce(fn, delay = 300) {
    let timer = null;
    return function (...args) {
        clearTimeout(timer);
        timer = setTimeout(() => {
            fn.apply(this, args);
        }, delay);
    };
}

// Uso: busqueda en vivo
const inputBusqueda = document.querySelector("#search-input");

inputBusqueda.addEventListener("input", debounce((e) => {
    const query = e.target.value;
    if (query.length < 3) return;

    // Hacer la busqueda (API call, filtrar lista, etc.)
    buscarResultados(query);
}, 300));
// Solo se ejecuta 300ms DESPUES de que el usuario deje de escribir

// === THROTTLE ===
// Ejecuta como maximo una vez cada Nms
// Garantiza ejecucion periodica durante el evento

function throttle(fn, limit = 100) {
    let inThrottle = false;
    return function (...args) {
        if (!inThrottle) {
            fn.apply(this, args);
            inThrottle = true;
            setTimeout(() => {
                inThrottle = false;
            }, limit);
        }
    };
}

// Uso: actualizar indicador de scroll
const indicador = document.querySelector("#scroll-progress");

window.addEventListener("scroll", throttle(() => {
    const scrollTop = document.documentElement.scrollTop;
    const scrollHeight = document.documentElement.scrollHeight - window.innerHeight;
    const progress = (scrollTop / scrollHeight) * 100;
    indicador.style.width = `${progress}%`;
}, 16)); // ~60fps (1000ms / 60fps = ~16ms)

// Uso: resize handler
window.addEventListener("resize", throttle(() => {
    console.log("Ancho:", window.innerWidth);
    recalcularLayout();
}, 250));

// === Debounce con opción leading ===
// Ejecuta inmediatamente en la primera llamada, despues debouncea

function debounceLeading(fn, delay = 300) {
    let timer = null;
    return function (...args) {
        if (!timer) {
            fn.apply(this, args); // ejecutar inmediatamente
        }
        clearTimeout(timer);
        timer = setTimeout(() => {
            timer = null; // permitir la proxima ejecucion inmediata
        }, delay);
    };
}

// Uso: boton de "guardar" que solo permite un click por segundo
document.querySelector("#btn-guardar").addEventListener("click",
    debounceLeading(() => {
        guardarDatos();
    }, 1000)
);

// === Combinacion real: buscador con debounce + loading state ===

const searchInput = document.querySelector("#busqueda");
const resultsContainer = document.querySelector("#resultados");
const loadingIndicator = document.querySelector("#loading");

searchInput.addEventListener("input", debounce(async (e) => {
    const query = e.target.value.trim();
    if (!query) {
        resultsContainer.innerHTML = "";
        return;
    }

    loadingIndicator.classList.remove("hidden");
    try {
        const resultados = await fetch(`/api/search?q=${query}`);
        const data = await resultados.json();
        renderResultados(data);
    } catch (error) {
        resultsContainer.innerHTML = "<p>Error de b&uacute;squeda</p>";
    } finally {
        loadingIndicator.classList.add("hidden");
    }
}, 400));

Lodash vs implementación propia

Las implementaciones de debounce y throttle que mostramos acá cubren el 90% de los casos de uso. Si necesitás opciones avanzadas (como leading + trailing combinados, cancel, flush), Lodash tiene implementaciones robustas con _.debounce() y _.throttle(). Para proyectos simples, las versiones caseras son perfectas y evitás una dependencia extra.