Events & Delegation
Browser events let you respond to user actions โ clicks, keypresses, form submissions. Learn event listeners, the event object, bubbling, and the efficient event delegation pattern.
Adding Event Listeners
const button = document.querySelector("#myBtn");
// addEventListener is the modern, preferred approach
button.addEventListener("click", function(event) {
console.log("Clicked!", event);
});
// Arrow function
button.addEventListener("click", (e) => {
console.log("Button clicked at:", e.clientX, e.clientY);
});
// Named function (easier to remove later)
function handleClick(e) {
console.log("Clicked");
}
button.addEventListener("click", handleClick);
button.removeEventListener("click", handleClick); // remove exact same reference
โ ๏ธAvoid inline handlers and onclick property
element.onclick = fn overwrites any previous handler. addEventListener stacks handlers safely and gives you more control (capture phase, once, etc.).
The Event Object
The event handler receives an Event object with information about what happened:
button.addEventListener("click", (e) => {
e.type; // "click"
e.target; // the element that was clicked
e.currentTarget; // the element the listener is attached to
e.timeStamp; // when the event occurred
// Mouse events
e.clientX; e.clientY; // position relative to viewport
e.pageX; e.pageY; // position relative to document
e.button; // 0=left, 1=middle, 2=right
// Keyboard events
e.key; // "Enter", "a", "ArrowLeft"
e.code; // "KeyA", "Space", "Enter" (physical key)
e.ctrlKey; // true if Ctrl held
e.metaKey; // true if Cmd (Mac) / Win key held
e.shiftKey; e.altKey;
// Prevent default behavior
e.preventDefault(); // stop browser's default action (e.g., form submit, link follow)
// Stop propagation
e.stopPropagation(); // stop event from bubbling further
});
Common Events
// Mouse
element.addEventListener("click", handler);
element.addEventListener("dblclick", handler);
element.addEventListener("mouseenter", handler); // hover start (no bubbling)
element.addEventListener("mouseleave", handler); // hover end (no bubbling)
element.addEventListener("mousemove", handler);
element.addEventListener("mousedown", handler);
element.addEventListener("mouseup", handler);
element.addEventListener("contextmenu", handler); // right-click
// Keyboard
document.addEventListener("keydown", handler);
document.addEventListener("keyup", handler);
document.addEventListener("keypress", handler); // deprecated, use keydown
// Form
form.addEventListener("submit", (e) => {
e.preventDefault(); // stop form from navigating/reloading
const data = new FormData(form);
});
input.addEventListener("change", handler); // fires when value changes and loses focus
input.addEventListener("input", handler); // fires on every keystroke
input.addEventListener("focus", handler);
input.addEventListener("blur", handler);
// Window/Document
window.addEventListener("load", handler); // all resources loaded
document.addEventListener("DOMContentLoaded", handler); // HTML parsed (before images)
window.addEventListener("resize", handler);
window.addEventListener("scroll", handler);
Event Bubbling and Capturing
Events travel through the DOM in two phases:
- Capture phase โ from document down to the target
- Bubble phase โ from target back up to document
<div id="outer">
<div id="middle">
<button id="inner">Click</button>
</div>
</div>
// By default, listeners use the BUBBLE phase
document.getElementById("outer").addEventListener("click", () => {
console.log("outer (bubble)"); // fires 3rd
});
document.getElementById("middle").addEventListener("click", () => {
console.log("middle (bubble)"); // fires 2nd
});
document.getElementById("inner").addEventListener("click", () => {
console.log("inner (bubble)"); // fires 1st (target)
});
// Click inner: "inner" โ "middle" โ "outer"
// Use capture phase with {capture: true}
document.getElementById("outer").addEventListener("click", () => {
console.log("outer (capture)"); // fires 1st (before target)
}, { capture: true });
stopPropagation and preventDefault
// Stop bubbling
inner.addEventListener("click", (e) => {
e.stopPropagation(); // "outer" and "middle" handlers won't fire
});
// Prevent default browser behavior
const link = document.querySelector("a");
link.addEventListener("click", (e) => {
e.preventDefault(); // stop navigation
console.log("Link clicked but not followed");
});
const form = document.querySelector("form");
form.addEventListener("submit", (e) => {
e.preventDefault(); // stop page reload
// handle form with JS instead
});
Event Delegation
Instead of attaching listeners to each child, attach ONE listener to the parent. Use event.target to identify which child was clicked:
// โ Inefficient โ one listener per item
document.querySelectorAll(".list-item").forEach(item => {
item.addEventListener("click", handler);
});
// โ Event delegation โ one listener on the parent
const list = document.querySelector(".list");
list.addEventListener("click", (e) => {
// Check if the clicked element matches what we want
const item = e.target.closest(".list-item");
if (!item) return; // clicked outside a list item
console.log("Clicked item:", item.dataset.id, item.textContent);
});
Benefits of delegation:
- Works for dynamically added elements (no need to re-attach)
- Fewer event listeners = better memory/performance
- Single place to manage list behavior
once, passive, and signal Options
// {once: true} โ auto-removes after first call
button.addEventListener("click", handler, { once: true });
// {passive: true} โ promises not to call preventDefault
// Used for scroll/touch handlers to improve performance
window.addEventListener("scroll", handler, { passive: true });
window.addEventListener("touchmove", handler, { passive: true });
// AbortController โ remove multiple listeners at once
const controller = new AbortController();
const { signal } = controller;
document.addEventListener("click", handler, { signal });
document.addEventListener("keydown", handler, { signal });
// Remove all listeners
controller.abort(); // both handlers removed!
Custom Events
// Create and dispatch custom events
const event = new CustomEvent("user-login", {
detail: { userId: 42, name: "Alice" }, // custom data
bubbles: true,
cancelable: true,
});
document.dispatchEvent(event);
// Listen for it
document.addEventListener("user-login", (e) => {
console.log("User logged in:", e.detail.name);
});
Throttle and Debounce
High-frequency events (scroll, resize, input) need rate limiting:
// Debounce โ fires only after activity stops for 'wait' ms
function debounce(fn, wait) {
let timer;
return function(...args) {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), wait);
};
}
// Good for: search input, window resize
const handleSearch = debounce((e) => {
fetchResults(e.target.value);
}, 300);
input.addEventListener("input", handleSearch);
// Throttle โ fires at most once per 'limit' ms
function throttle(fn, limit) {
let lastRan;
return function(...args) {
if (!lastRan || Date.now() - lastRan >= limit) {
lastRan = Date.now();
fn.apply(this, args);
}
};
}
// Good for: scroll, mousemove
const handleScroll = throttle(() => {
updateScrollIndicator();
}, 100);
window.addEventListener("scroll", handleScroll);
Key Takeaways
addEventListeneris the correct way to attach event handlers- The event object contains info about what happened (
target, position, keys, etc.) e.preventDefault()stops browser defaults;e.stopPropagation()stops bubbling- Events bubble from target โ ancestors (capture goes the other direction)
- Event delegation: one parent listener handles all child events โ works for dynamic elements
{ once: true }auto-removes;{ passive: true }signals nopreventDefaultfor scroll events- Debounce and throttle prevent handlers from firing too frequently
Ready to test your knowledge?
Take a quiz on what you just learned.