addEventListener
addEventListener is the standard way to make an element respond to events. Unlike inline on* attributes or DOM properties, it lets you attach multiple handlers to the same target, configure precise behavior through an options object, and detach handlers cleanly with removeEventListener. Getting this right is what keeps long-lived apps responsive and free of memory leaks.
The signature
Every DOM node, plus document and window, exposes addEventListener. It takes the event type, the handler function, and an optional third argument that tunes how the listener behaves.
target.addEventListener(type, handler, options);
| Parameter | Type | Description |
|---|---|---|
type | string | The event name, without the on prefix — "click", "input", "keydown". |
handler | function | Called with the Event object when the event fires. |
options | object | boolean | Optional. An options object, or a boolean shorthand for capture. |
The handler receives the Event as its first argument, and inside it this refers to the element the listener is attached to (use a regular function, not an arrow, if you rely on this).
const btn = document.querySelector("#save");
btn.addEventListener("click", function (event) {
console.log("clicked", event.type, this.id);
});
Output:
clicked click save
Attaching multiple handlers
The key advantage over element.onclick = fn is that listeners stack. Each call to addEventListener adds another handler; they all run, in the order they were registered. Adding the exact same function reference for the same type and capture phase twice is a no-op — the duplicate is ignored.
const btn = document.querySelector("#save");
btn.addEventListener("click", () => console.log("validate"));
btn.addEventListener("click", () => console.log("persist"));
btn.addEventListener("click", () => console.log("notify"));
// One click logs all three, in order.
The options object
The third argument unlocks behavior you cannot get any other way. Pass an object with any combination of these flags.
| Option | Default | Effect |
|---|---|---|
once | false | The handler runs at most once, then auto-removes itself. |
capture | false | Fire during the capture phase (top-down) instead of bubbling (bottom-up). |
passive | false | Promises you won’t call preventDefault, letting the browser optimize scrolling. |
signal | — | An AbortSignal; aborting it removes the listener. |
// Runs exactly once, then detaches automatically.
button.addEventListener("click", handleFirstClick, { once: true });
// Improves scroll performance for high-frequency events.
window.addEventListener("scroll", onScroll, { passive: true });
Passing a boolean as the third argument is the legacy form:
truemeans{ capture: true }. Prefer the explicit object — it is clearer and supports the other flags.
The interactive demo below shows once and stacked handlers working together. Click the buttons and watch the log.
<!DOCTYPE html>
<html>
<head>
<style>
body { font-family: system-ui, sans-serif; padding: 24px; }
button { font-size: 1rem; padding: 8px 14px; margin-right: 8px; cursor: pointer; }
#log { margin-top: 16px; font-family: monospace; white-space: pre-line; }
</style>
</head>
<body>
<button id="multi">Stacked handlers</button>
<button id="once">Runs once</button>
<div id="log"></div>
<script>
const log = document.querySelector("#log");
const print = (msg) => (log.textContent += msg + "\n");
const multi = document.querySelector("#multi");
multi.addEventListener("click", () => print("handler A"));
multi.addEventListener("click", () => print("handler B"));
const once = document.querySelector("#once");
once.addEventListener("click", () => print("fired (won't fire again)"), {
once: true,
});
</script>
</body>
</html>
Removing listeners
removeEventListener(type, handler, options) detaches a previously added listener. The catch: you must pass the same function reference and a matching capture value. An inline arrow function can never be removed because you have no reference to it.
function onResize() {
console.log("window resized");
}
window.addEventListener("resize", onResize);
// Later — must be the same named reference:
window.removeEventListener("resize", onResize);
This is why the example below stores the handler in a variable. The named reference is what makes removal possible.
<!DOCTYPE html>
<html>
<head>
<style>
body { font-family: system-ui, sans-serif; padding: 24px; }
button { font-size: 1rem; padding: 8px 14px; margin-right: 8px; cursor: pointer; }
#area { margin-top: 16px; padding: 24px; border: 2px dashed #888; }
</style>
</head>
<body>
<button id="stop">Disable tracking</button>
<div id="area">Move your mouse here. Position: <span id="pos">—</span></div>
<script>
const area = document.querySelector("#area");
const pos = document.querySelector("#pos");
function trackMouse(event) {
pos.textContent = `${event.offsetX}, ${event.offsetY}`;
}
area.addEventListener("mousemove", trackMouse);
document.querySelector("#stop").addEventListener("click", () => {
area.removeEventListener("mousemove", trackMouse);
pos.textContent = "tracking off";
});
</script>
</body>
</html>
Cleanup with AbortController
Removing many listeners by hand is tedious. An AbortController lets you tear down every listener bound to one signal in a single call — ideal when a component unmounts or a view is destroyed.
const controller = new AbortController();
const { signal } = controller;
input.addEventListener("input", onInput, { signal });
window.addEventListener("resize", onResize, { signal });
document.addEventListener("keydown", onKey, { signal });
// One call removes all three listeners at once:
controller.abort();
Listeners on elements that stay in the DOM keep their handler functions — and everything those closures capture — alive in memory. If you create and discard elements over a session without removing their listeners, you leak. Always clean up.
Best Practices
- Prefer
addEventListeneroveron*properties so you can stack handlers and use options. - Keep a named reference for any handler you intend to remove; arrow functions passed inline cannot be detached.
- Use
{ once: true }for one-shot handlers instead of manually removing them inside the callback. - Add
{ passive: true }toscroll,touchmove, andwheellisteners to keep scrolling smooth. - Use an
AbortControllersignalto remove groups of listeners in one call during cleanup. - Match the
captureflag when removing — a listener added with{ capture: true }won’t be removed by a default-phase call. - Always remove listeners (or use a signal) when the target outlives the listener, to avoid memory leaks.