Skip to content

Event Handling and Delegation

beginner16 min read

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.

Mental Model

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)
});
Common Trap

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!
Quiz
You add a click listener with an anonymous arrow function, then try to remove it with another identical arrow function. Does the listener get removed?

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
Quiz
When you click a button nested inside a div, which fires first: the div's capture listener or the button's regular listener?

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
});
Don't abuse stopPropagation

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.

Execution Trace
User clicks
Clicks on an icon inside .todo-item
event.target = the icon element
Bubbling
Event bubbles from icon up to .todo-list
Listener on .todo-list fires
closest check
e.target.closest('.todo-item') returns the parent .todo-item
Found the logical target
Handle
Read item.dataset.id and process the click
Works for any number of items
Quiz
Why is event delegation better than attaching individual listeners to 1000 list items?

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.

Key Rules
  1. 1Always use addEventListener — never inline onclick handlers
  2. 2Events bubble up by default: target first, then ancestors
  3. 3Use event delegation (one parent listener + closest) instead of listeners on every child
  4. 4preventDefault cancels browser defaults; stopPropagation stops propagation — don't confuse them
  5. 5Use passive: true on scroll/touch handlers for smooth scrolling performance
What developers doWhat 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