Difficulty: Intermediate
Events are the mechanism through which JavaScript interacts with user actions and browser activities. Every click, keystroke, mouse movement, form submission, and page load fires an event that JavaScript can listen for and respond to. Understanding the event system deeply - including how events propagate through the DOM tree - is fundamental to building interactive web applications and is a frequently tested topic in interviews.
The primary way to attach event handlers is addEventListener(type, callback, options). The third argument can be a boolean (true for capture phase) or an options object with capture, once, passive, and signal properties. To remove a listener, call removeEventListener with the exact same function reference - this is why anonymous functions cannot be removed. The older onclick-style properties work but only allow one handler per event type.
When an event fires on an element, it does not just trigger on that element. It goes through three phases: capture phase (the event travels from the window down through ancestors to the target), target phase (the event reaches the actual element that was interacted with), and bubble phase (the event travels back up from the target through ancestors to the window). By default, addEventListener registers handlers for the bubble phase. Setting capture: true or passing true as the third argument registers for the capture phase instead.
The event object passed to handlers contains essential properties. event.target is the actual element that triggered the event (the deepest element clicked). event.currentTarget is the element the handler is attached to. event.type is the event name. event.stopPropagation() prevents the event from continuing to propagate. event.preventDefault() prevents the browser's default behavior (like form submission or link navigation). event.stopImmediatePropagation() also prevents other handlers on the same element from firing.
Event delegation is a pattern where you attach a single event listener to a parent element instead of individual listeners on each child. When an event bubbles up from a child, the parent's handler checks event.target to determine which child was clicked and responds accordingly. This has three major benefits: it works automatically with dynamically added elements, it uses less memory (one handler vs many), and it simplifies setup and teardown. Event delegation is especially powerful for lists, tables, and any container with many interactive children.
CustomEvent allows you to create and dispatch your own events with custom data. You create one with new CustomEvent('eventName', { detail: data, bubbles: true }) and dispatch it with element.dispatchEvent(customEvent). This is useful for component communication, plugin APIs, and decoupling parts of your application. Custom events follow the same capture/bubble propagation as native events.
const outer = document.querySelector('#outer');
const inner = document.querySelector('#inner');
const button = document.querySelector('#btn');
// Bubble phase (default)
outer.addEventListener('click', () => console.log('Outer - bubble'));
inner.addEventListener('click', () => console.log('Inner - bubble'));
button.addEventListener('click', () => console.log('Button - bubble'));
// Capture phase
outer.addEventListener('click', () => console.log('Outer - capture'), true);
inner.addEventListener('click', () => console.log('Inner - capture'), true);
// When button is clicked, order is:
// 1. Outer - capture (capture phase, top down)
// 2. Inner - capture (capture phase, continues down)
// 3. Button - bubble (target phase)
// 4. Inner - bubble (bubble phase, bottom up)
// 5. Outer - bubble (bubble phase, continues up)
// Options object syntax
button.addEventListener('click', () => console.log('Once!'), {
once: true, // automatically removed after first call
passive: true // hints browser that preventDefault won't be called
});
Events travel down during capture (window to target), reach the target, then bubble back up (target to window). Capture handlers fire first, then bubble handlers. The 'once' option auto-removes the listener after one invocation.
const list = document.querySelector('ul');
list.addEventListener('click', function(e) {
console.log('target:', e.target.tagName); // actual clicked element
console.log('currentTarget:', e.currentTarget.tagName); // element with listener
console.log('this:', this.tagName); // same as currentTarget
});
// If you click on an <li> inside the <ul>:
// target: LI
// currentTarget: UL
// this: UL
// stopPropagation and preventDefault
const link = document.querySelector('a.special');
link.addEventListener('click', (e) => {
e.preventDefault(); // don't navigate to href
e.stopPropagation(); // don't let parent handlers fire
console.log('Link clicked but default behavior prevented');
});
event.target is the element the user actually interacted with. event.currentTarget is the element the listener is attached to. preventDefault stops default browser behavior, stopPropagation stops further event propagation.
// Instead of adding listeners to every button:
// BAD:
// document.querySelectorAll('.delete-btn').forEach(btn => {
// btn.addEventListener('click', handleDelete);
// });
// ^ breaks when new buttons are added dynamically
// GOOD: Event delegation - one listener on the parent
const todoList = document.querySelector('#todo-list');
todoList.addEventListener('click', (e) => {
const target = e.target;
// Handle delete button clicks
if (target.matches('.delete-btn')) {
const item = target.closest('.todo-item');
const id = item.dataset.id;
console.log(`Deleting todo ${id}`);
item.remove();
return;
}
// Handle edit button clicks
if (target.matches('.edit-btn')) {
const item = target.closest('.todo-item');
console.log(`Editing todo ${item.dataset.id}`);
return;
}
// Handle checkbox toggles
if (target.matches('input[type="checkbox"]')) {
const item = target.closest('.todo-item');
item.classList.toggle('completed');
console.log(`Toggled todo ${item.dataset.id}`);
}
});
// This works even for dynamically added items!
const newItem = document.createElement('li');
newItem.className = 'todo-item';
newItem.dataset.id = '99';
newItem.innerHTML = '<span>New Task</span><button class="delete-btn">X</button>';
todoList.appendChild(newItem);
A single listener on the parent handles events for all children by checking event.target. The matches() method tests if the target matches a CSS selector, and closest() finds the nearest ancestor matching a selector. This pattern works automatically for dynamically added elements.
// Define custom events for component communication
const cartElement = document.querySelector('#cart');
// Listen for custom event
cartElement.addEventListener('item:added', (e) => {
const { name, price, quantity } = e.detail;
console.log(`Added ${quantity}x ${name} (${price}) to cart`);
});
cartElement.addEventListener('item:removed', (e) => {
console.log(`Removed ${e.detail.name} from cart`);
});
// Dispatch custom events from anywhere
function addToCart(product) {
// ... add item logic ...
const event = new CustomEvent('item:added', {
detail: product,
bubbles: true // allows parent elements to hear it too
});
cartElement.dispatchEvent(event);
}
addToCart({ name: 'Laptop', price: 999, quantity: 1 });
addToCart({ name: 'Mouse', price: 29, quantity: 2 });
// Events can bubble to document for global handling
document.addEventListener('item:added', (e) => {
console.log(`Global handler: cart updated`);
});
CustomEvent allows you to create application-specific events with arbitrary data in the detail property. When bubbles is true, the event propagates up the DOM tree just like native events, enabling decoupled communication.
addEventListener, Event Bubbling, Event Capturing, Event Delegation, stopPropagation, preventDefault, CustomEvent