JavaScriptEvent Listeners

Event Listeners

addEventListener is the workhorse of every interactive page on the web. The basic call is two arguments — an event name and a function — but the real depth is in the third argument: the options object. Capture phase, once-only listeners, passive scroll, and the modern AbortController cleanup story all live there. This page covers everything you actually need to manage listeners safely.

The basic signature

JS
target.addEventListener(type, listener, options);
target.removeEventListener(type, listener, options);

type is the event name as a string. listener is the function to call. options is either a boolean (legacy useCapture flag) or an object — almost always an object in modern code.

removeEventListener needs the same reference

This is the most common bug newcomers hit. removeEventListener only removes a listener if you pass exactly the same function reference that was registered.

does NOT remove anything

JS
btn.addEventListener("click", () => console.log("hi"));
btn.removeEventListener("click", () => console.log("hi"));
// Two different anonymous functions — the second call is a no-op.

correct

JS
function onClick() {
  console.log("hi");
}

btn.addEventListener("click", onClick);
// ... later ...
btn.removeEventListener("click", onClick);
Bound methods are new functions
`obj.method.bind(obj)` and `(e) => this.method(e)` each create a *new* function. If you pass one to `addEventListener` you must keep the reference around for the matching `removeEventListener` call.
The options object

Three fields cover almost every real use case.

  • capture — listen during the capture phase instead of the bubble phase. We unpack phases on the bubbling page.

  • once — automatically remove the listener after it fires once. Perfect for "fire-and-forget" handlers.

  • passive — promise the browser you will not call preventDefault. Required for high-performance scrolling on mobile.

JS
btn.addEventListener("click", onClick, {
  capture: false,
  once: true,
  passive: true,
});
once: true

run only the first time

JS
const banner = document.querySelector(".cookie-banner");

banner.addEventListener("click", () => banner.remove(), { once: true });
// First click hides the banner; the listener is then gone automatically.

This saves you from writing the boilerplate of removing the listener inside itself, and it removes the need to keep a named reference just for the cleanup.

passive: true

Touch and wheel events on mobile have a special problem. The browser cannot start scrolling until the listener has run, because the listener might call preventDefault. If many listeners are attached, scrolling stutters. Declaring a listener as passive: true tells the browser "I will never preventDefault this event" — the browser can scroll in parallel and the page feels instant.

JS
window.addEventListener("scroll", onScroll, { passive: true });
window.addEventListener("touchmove", onTouchMove, { passive: true });
Modern defaults
Browsers already treat `touchstart`, `touchmove` and `wheel` as passive by default when attached to the root. Setting it explicitly documents intent and works on older targets.
capture: true

Most listeners run during the bubble phase. Setting capture: true puts the listener on the capture phase instead — it fires on the way down from window to the target rather than on the way back up. Useful for global instrumentation, focus trapping, and a few edge cases. See the event-bubbling page for the full phase walkthrough.

AbortController for cleanup

Tracking individual listener references for removal is painful when a component has many. Modern DOM APIs accept an AbortSignal that removes the listener when the signal is aborted. One abort() call tears down everything.

component-style cleanup

JS
function mountWidget(root) {
  const controller = new AbortController();
  const { signal } = controller;

  root.addEventListener("click", onClick, { signal });
  window.addEventListener("resize", onResize, { signal });
  document.addEventListener("keydown", onKey, { signal });

  return function unmount() {
    controller.abort(); // removes all three listeners in one call
  };
}

This pattern is now the cleanest way to manage lifetimes — no named references, no per-listener removal, no leaks if you forget one.

this inside handlers

In a regular function passed to addEventListener, this is the element (the same as event.currentTarget). In an arrow function, this is whatever this was in the enclosing scope.

JS
btn.addEventListener("click", function () {
  this; // === btn
});

btn.addEventListener("click", () => {
  this; // === enclosing scope's this, NOT btn
});
Listener-friendly checklist
  • Always pair addEventListener with a removal path — same reference, or an AbortSignal.

  • Use once: true for one-shot handlers instead of removing yourself.

  • Mark scroll, wheel and touch handlers as passive: true unless you really need preventDefault.

  • Inline arrow handlers in render-on-every-update code are leaks waiting to happen — store the reference.

  • When you must handle the same event in many places, prefer event delegation (covered separately) over many listeners.