JavaScript Event Listener

addEventListener() is the standard way to register event handlers in modern JavaScript. This chapter takes a deeper look at its options, event propagation (bubbling vs capturing), event delegation, and best practices for cleaning up listeners.

addEventListener() Syntax

The full signature is: element.addEventListener(type, listener, options). The options parameter is an object that can include capture, once, passive, and signal.

Full Syntax
const btn = document.getElementById("myBtn");

function handleClick(e) {
  console.log("Clicked!", e.type);
}

// Basic usage
btn.addEventListener("click", handleClick);

// With options object
btn.addEventListener("click", handleClick, {
  capture: false,  // listen in bubble phase (default)
  once: false,     // do not auto-remove after first call
  passive: false   // allow preventDefault()
});

console.log("Listener attached with options");

Multiple Listeners on One Element

Unlike on-event properties, addEventListener lets you attach multiple handlers for the same event on the same element. They run in the order they were registered.

Attaching Multiple Listeners
const input = document.getElementById("search");

input.addEventListener("input", () => {
  console.log("Listener 1: validate input");
});

input.addEventListener("input", () => {
  console.log("Listener 2: update suggestions");
});

input.addEventListener("input", () => {
  console.log("Listener 3: track analytics");
});

// When the user types, all three fire in order:
// Listener 1: validate input
// Listener 2: update suggestions
// Listener 3: track analytics

Event Options: once, passive, capture

The options object gives fine-grained control over how and when the listener fires.

OptionTypeDescription
capturebooleanIf true, fires during the capture phase (top-down) instead of the bubble phase (bottom-up)
oncebooleanIf true, the listener auto-removes itself after firing once
passivebooleanIf true, tells the browser the handler will never call preventDefault() — improves scroll performance
signalAbortSignalAn AbortSignal; when aborted, the listener is removed
Using once and passive
const btn = document.getElementById("myBtn");

// Fires only once, then auto-removes
btn.addEventListener("click", () => {
  console.log("This runs only once!");
}, { once: true });

// Passive scroll listener — improves performance
window.addEventListener("scroll", () => {
  console.log("Scrolled! Y:", window.scrollY);
}, { passive: true });

console.log("Listeners registered");

Bubbling vs Capturing

When an event fires, it goes through three phases: capture (root to target), target, and bubble (target back to root). By default listeners fire in the bubble phase. Set capture: true to listen during the capture phase instead.

Capture vs Bubble Phase
// <div id="outer"><button id="inner">Click</button></div>

const outer = document.getElementById("outer");
const inner = document.getElementById("inner");

outer.addEventListener("click", () => {
  console.log("outer — capture phase");
}, { capture: true });

inner.addEventListener("click", () => {
  console.log("inner — target/bubble phase");
});

outer.addEventListener("click", () => {
  console.log("outer — bubble phase");
});

// Clicking the inner button logs:
// outer — capture phase
// inner — target/bubble phase
// outer — bubble phase

Event Delegation

Instead of attaching a listener to every child element, attach one listener to a common parent and use event.target to determine which child was clicked. This is more efficient and works for dynamically added elements.

Event Delegation Pattern
const list = document.getElementById("todo-list");

// One listener on the parent <ul>
list.addEventListener("click", (e) => {
  // Only act if the click was on an <li>
  if (e.target.tagName === "LI") {
    e.target.classList.toggle("done");
    console.log("Toggled:", e.target.textContent);
  }
});

// Works even for items added later!
const newItem = document.createElement("li");
newItem.textContent = "New task";
list.appendChild(newItem);
console.log("New item added — delegation handles it automatically");
removeEventListener
function onResize() {
  console.log("Window resized:", window.innerWidth);
}

window.addEventListener("resize", onResize);

// Later, clean up
window.removeEventListener("resize", onResize);
console.log("Resize listener removed");

// Using AbortController (modern alternative)
const controller = new AbortController();
window.addEventListener("resize", onResize, { signal: controller.signal });

// Remove all listeners attached with this signal
controller.abort();
console.log("All listeners aborted via AbortController");
📝 Note: Use event delegation whenever you have many similar child elements or dynamic content. It reduces memory usage, simplifies code, and handles elements added after the initial page load.
Exercise:
In which phase does a default addEventListener() listener fire?
Try it YourselfCtrl+Enter to run
Click Run to see the output here.