Event Handling and Delegation
The Web Runs on Events
Click a button. Type in an input. Scroll the page. Resize the window. Every interaction a user has with a webpage fires an event. Your job as a developer is to listen for the events you care about and respond to them. That's it. That's the core interaction model of the entire web platform.
But the event system has depth that most developers never explore. Understanding how events propagate through the DOM tree — and how to exploit that behavior — will make you dramatically more effective at building interactive UIs.
Picture dropping a pebble into a pond. The pebble hits a specific point (the target element), but the ripples spread outward through the water (the DOM tree). Events work the same way — they start at a target element but travel through the entire ancestor chain. You can place a listener anywhere along that path and catch events from any descendant. This is the foundation of event delegation, and it's one of the most important patterns in frontend development.
addEventListener — The Right Way
There are several ways to attach event listeners, but addEventListener is the only one you should use. It lets you attach multiple handlers to the same event, control the phase, and configure options.
const button = document.querySelector('button');
button.addEventListener('click', function(event) {
console.log('Button clicked!');
console.log('Target:', event.target);
});
The Event Object
Every handler receives an event object with details about what happened:
button.addEventListener('click', (event) => {
event.target; // the element that was actually clicked
event.currentTarget; // the element the listener is attached to
event.type; // "click"
event.timeStamp; // when the event fired
event.clientX; // mouse X position (for mouse events)
event.clientY; // mouse Y position
event.key; // the key pressed (for keyboard events)
});
event.target and event.currentTarget are often different. If you click a span inside a button, event.target is the span (what you actually clicked), but event.currentTarget is the button (where the listener is attached). This distinction matters enormously for event delegation.
Removing Event Listeners
To remove a listener, you need a reference to the exact same function:
function handleClick(event) {
console.log('clicked');
}
button.addEventListener('click', handleClick);
// Later — remove it
button.removeEventListener('click', handleClick);
// THIS DOES NOT WORK — anonymous functions create new references each time
button.addEventListener('click', () => console.log('hi'));
button.removeEventListener('click', () => console.log('hi')); // different function!
Event Propagation: Bubbling and Capturing
When you click an element, the event doesn't just fire on that element. It travels through the DOM tree in three phases:
<div id="outer">
<div id="inner">
<button id="btn">Click me</button>
</div>
</div>
// All three fire when you click the button
document.getElementById('outer').addEventListener('click', () => {
console.log('outer'); // fires during bubbling
});
document.getElementById('inner').addEventListener('click', () => {
console.log('inner'); // fires during bubbling
});
document.getElementById('btn').addEventListener('click', () => {
console.log('btn'); // fires at target phase
});
// Output when clicking the button: btn, inner, outer
// The event bubbles UP from target to ancestors
Listening During the Capture Phase
Pass { capture: true } (or just true as the third argument) to listen during the capture phase instead:
document.getElementById('outer').addEventListener('click', () => {
console.log('outer capture');
}, { capture: true }); // or just: }, true);
document.getElementById('btn').addEventListener('click', () => {
console.log('btn');
});
// Output: outer capture, btn
// Capture listeners fire BEFORE bubbling listeners
stopPropagation and preventDefault
stopPropagation — Stop the Event from Traveling
stopPropagation prevents the event from continuing to the next element in the propagation chain.
document.getElementById('inner').addEventListener('click', (e) => {
e.stopPropagation();
console.log('inner');
// The event stops here — outer's listener never fires
});
preventDefault — Cancel the Default Browser Action
Many events trigger a default browser behavior. preventDefault cancels that behavior without stopping propagation.
// Prevent a link from navigating
link.addEventListener('click', (e) => {
e.preventDefault();
console.log('Link clicked but page did not navigate');
});
// Prevent form submission
form.addEventListener('submit', (e) => {
e.preventDefault();
// Handle submission with JavaScript instead
});
// Prevent right-click context menu
element.addEventListener('contextmenu', (e) => {
e.preventDefault();
// Show custom context menu
});
Calling stopPropagation breaks event delegation and can cause subtle bugs. Analytics tools, accessibility features, and other code higher in the tree might depend on events bubbling up. Use it sparingly — if you think you need it, you might actually need event delegation instead.
Event Delegation
This is one of the most important patterns in DOM programming. Instead of attaching listeners to every individual element, you attach one listener to a parent and use the event's target to figure out which child was interacted with.
The Problem Without Delegation
// BAD — one listener per item
const items = document.querySelectorAll('.todo-item');
items.forEach(item => {
item.addEventListener('click', handleClick);
});
// Problems:
// 1. 1000 items = 1000 listeners = more memory
// 2. Dynamically added items don't get listeners
// 3. You have to manage cleanup for removed items
The Solution With Delegation
// GOOD — one listener on the parent
document.querySelector('.todo-list').addEventListener('click', (e) => {
const item = e.target.closest('.todo-item');
if (!item) return; // click wasn't on an item
console.log('Clicked item:', item.dataset.id);
});
// Benefits:
// 1. One listener regardless of how many items
// 2. Works for items added dynamically (future items)
// 3. No cleanup needed when items are removed
The key is e.target.closest('.todo-item'). The user might click a span or an icon inside the todo item. closest walks up from the actual click target to find the nearest .todo-item ancestor, which is the logical element we care about.
Listener Options
addEventListener accepts an options object with three powerful settings:
once — Auto-Remove After First Fire
button.addEventListener('click', () => {
console.log('This fires only once');
}, { once: true });
// After the first click, the listener is automatically removed
passive — Promise You Won't preventDefault
// For scroll/touch events, passive: true tells the browser
// you won't call preventDefault, so it can start scrolling immediately
// without waiting for your handler to finish
document.addEventListener('touchstart', handleTouch, { passive: true });
Why passive listeners matter for scroll performance
When you add a touchstart or wheel listener, the browser has to wait for your handler to finish before it knows whether to scroll the page. Your handler might call preventDefault(), which would cancel the scroll. This waiting creates visible jank — the page freezes for the duration of your handler. Setting passive: true tells the browser "I promise I won't call preventDefault, so go ahead and start scrolling immediately." Chrome, Firefox, and Safari now default touchstart and wheel listeners on document and window to passive. But listeners on other elements still default to passive: false.
capture — Listen During Capture Phase
element.addEventListener('click', handler, { capture: true });
You can combine options:
element.addEventListener('click', handler, {
once: true,
passive: true,
capture: false
});
CustomEvent — Create Your Own Events
You're not limited to browser-provided events. CustomEvent lets you create and dispatch your own:
// Create a custom event with data
const event = new CustomEvent('item-selected', {
detail: { id: 42, name: 'Widget' },
bubbles: true
});
// Dispatch it from any element
element.dispatchEvent(event);
// Listen for it anywhere in the ancestor chain (thanks to bubbles: true)
document.addEventListener('item-selected', (e) => {
console.log(e.detail.id); // 42
console.log(e.detail.name); // "Widget"
});
Custom events are a clean way to communicate between unrelated parts of your JavaScript without tight coupling. They follow the same bubbling/capturing rules as native events.
- 1Always use addEventListener — never inline onclick handlers
- 2Events bubble up by default: target first, then ancestors
- 3Use event delegation (one parent listener + closest) instead of listeners on every child
- 4preventDefault cancels browser defaults; stopPropagation stops propagation — don't confuse them
- 5Use passive: true on scroll/touch handlers for smooth scrolling performance
| What developers do | What they should do |
|---|---|
| Attaching listeners to every dynamically created element Event delegation uses one listener for all current and future children. Individual listeners waste memory and miss dynamically added elements | Using event delegation on a stable parent element |
| Using stopPropagation to prevent default browser behavior stopPropagation stops the event from reaching other listeners. preventDefault cancels the browser's default action (like link navigation). They do completely different things | Using preventDefault to cancel default behavior |
| Passing anonymous functions to removeEventListener removeEventListener matches by function reference. Two identical anonymous functions are different objects — the removal silently fails | Storing the function in a variable and passing the same reference |