DOM Events
How an event travels through the page tree, how to stop it or cancel what the browser does next, why one listener can serve 10,000 rows, and how timing, React and production concerns change the picture.
The 30-second version
Three bugs every front-end developer meets sooner or later: a click handler that runs twice, a dropdown that closes when you click inside it, and a table of 10,000 rows that gets slow because every row has its own listener. All three come from the same mechanism. Once you see it, they stop being mysteries.
First, the words. The page you see is a tree of objects called the DOM: <html> contains <body>, which contains your <ul>, which contains <li> rows, and so on (the trees topic covers trees in general). An event is a small object the browser makes when something happens: a click, a key press, a field getting focus. A listener is a function you ask the browser to call when a certain event reaches a certain element.
button.addEventListener("click", (event) => {
console.log("clicked!", event.target); // event.target = the element that was clicked
});Most listeners you'll ever write run on the way up (the bubble phase). That one fact explains a lot:
Parents hear children
<ul> hears clicks on every <li> inside it, because each click bubbles up through the list. That's how one listener can serve 10,000 rows (§05).You can stop the trip
event.stopPropagation() ends the journey early, and event.preventDefault() cancels what the browser would do next, like following a link (§04).Timing matters
The plan: §02–§04 teach the mechanism (listeners, phases, stopping and default actions), §05–§07 the everyday patterns (delegation, which events bubble, keyboard and pointer input), §08–§10 timing and React, §11 production concerns. Then pitfalls, a quiz and a cheat sheet.
Events, listeners and their options
addEventListener(type, fn, options) puts fn on an element's list for that event type. The browser calls it with an event object each time that event reaches the element.The event object
Every Event listener receives one argument: the event. Here are the fields you'll use most:
| Field | What it tells you | Example |
|---|---|---|
event.type | Which event this is | "click", "keydown", "input" |
event.target | The element it happened on — the deepest element under the pointer | the <span> inside your button |
event.currentTarget | The element whose listener is running now | the <button> you called addEventListener on |
event.timeStamp | When it happened, in ms since the page loaded | 1834.2 |
event.isTrusted | true if a real user caused it, false if your code made it | true |
Plus details for each kind: clientX/clientY (pointer position) on mouse events, key on keyboard events, and so on. The difference between event.target and event.currentTarget becomes important in §05. For now, remember: target = where it happened, currentTarget = where you're listening.
Adding and removing
const button = document.querySelector("#buy");
function onBuy(event) {
console.log("buy!", event.type); // "buy! click"
}
button.addEventListener("click", onBuy); // start listening
button.removeEventListener("click", onBuy); // stop: same type, SAME functionThe browser keeps, for each element, a list of (event type, function, capture flag) entries. The capture flag says whether the listener runs on the event's way down the page tree or on its way back up; §03 explains both, and for now "up" is the default. Two rules about that list cause many real bugs:
- Adding the exact same function twice does nothing. The browser ignores the duplicate. But two arrow functions that look the same are two different functions, so both get added, and your handler runs twice. That is bug #1 from §01: a component that runs
addEventListener("click", () => …)every time it sets itself up. - Removing needs the exact same function and capture flag.
removeEventListener("click", () => …)removes nothing, because that arrow function is brand new. And if you added with{ capture: true }, you must remove with{ capture: true }too.
We checked both rules in headless Chromium (Chrome's engine, driven by a script with no visible window; we use it to measure every claim in this topic): the same function added twice ran 1 time, two identical-looking arrows ran 2 times, and a remove with the wrong capture flag left the listener in place. Try it yourself:
The options object
The third argument is an options object. Each option answers one question:
const controller = new AbortController();
button.addEventListener("click", onBuy, {
once: true, // remove me automatically after the first call
capture: false, // run on the way down (true) or the way up (false, the default) — §03
passive: true, // "I promise not to call preventDefault()" (which cancels the browser's own action, §04) — §08
signal: controller.signal, // remove me when controller.abort() is called
});
controller.abort(); // ← removes every listener that was given this signalonclick versus addEventListener
You'll also see button.onclick = fn and <button onclick="…"> in older code. That is a single slot: assigning it again replaces the previous function. addEventListener keeps a list, so a second listener doesn't overwrite the first. Use addEventListener in plain JavaScript. (React's onClick prop is a different thing entirely; see §10.)
Capture, target, bubble
Back to the sorting office from §01. The letter's route is decided before it starts moving: the browser takes the target element and lists every ancestor up to window. That list is the propagation path (you can see it yourself with event.composedPath()). Then the event walks that path in three phases. This journey is called Propagation.
| Phase | event.eventPhase | Where | Which listeners run |
|---|---|---|---|
| 1 · capture | 1 | window → … → the target's parent | only those added with { capture: true } |
| 2 · target | 2 | the target element itself | all of its listeners: capture ones first, then the rest |
| 3 · bubble | 3 | the target's parent → … → window | only those added without capture (the default) |
Here is a real log from headless Chromium. Three nested elements, outer > middle > inner, each with one capture listener and one bubble listener. We clicked inner:
outer capture (phase 1)
middle capture (phase 1)
inner capture (phase 2) ← at the target
inner bubble (phase 2) ← still at the target
middle bubble (phase 3)
outer bubble (phase 3)With listeners on window, document and <body> too, the outer layers wrap around this: window capture, document capture, body capture, …, body bubble, document bubble, window bubble.
Now try it. The boxes below are real elements with real listeners. Switch listeners on and off, click the button, then click the <div>'s padding, and see which listeners run and in what order. (Ignore the stop controls for now: §04 explains them and uses them.)
event.eventPhase when it ran: on the clicked element itself it is always target.Three things to notice while you play:
- Click the
<div>padding instead of the button. Now the<div>is the target. Its capture listener (if on) runs in phase 2, not phase 1, and the button's listeners don't run at all: the button isn't on the path. - Registration order only matters on one element. On the button, "listener 1" always runs before "listener 2" because it was added first. Between different elements, the phase decides.
- At the target, capture listeners run first. The target's capture listeners run before its non-capture ones, whatever order you added them in: we registered the bubble listener first and the capture one still ran first. This has been the rule in Chrome since version 89, after a change to the DOM standard; Firefox and Safari follow the same standard, but we only measured Chromium. Older browsers ran them in registration order, so old blog posts may tell you otherwise.
// Runs before ANY listener inside the page sees the click:
document.addEventListener("click", logClick, { capture: true });
// The old way to write the same thing: a bare `true` as the third argument.
document.addEventListener("click", logClick, true);Stopping events and default actions
stopPropagation() stops the event's journey. preventDefault() cancels the browser's own reaction. They are independent: one does not imply the other.Three methods, three different jobs
| Call | What it stops | What still happens |
|---|---|---|
event.stopPropagation() | the event moving to the next element on the path | other listeners on the current element still run; the default action still happens |
event.stopImmediatePropagation() | the next element and the remaining listeners on this element | the default action still happens |
event.preventDefault() | the browser's default action | the event keeps travelling; every listener still runs |
We measured the first two on a button with two listeners and a parent <div> listener. The first button listener calls the method:
stopPropagation(): btn handler 1, btn handler 2 ← div never hears it
stopImmediatePropagation(): btn handler 1 ← nothing else runsYou can reproduce this in the propagation lab in §03: pick "button · listener 1" in the drop-down, then flip the toggle at the top between the two methods.
Default actions
Many events come with something the browser does by itself once your listeners have run. preventDefault() cancels it, but only if the event is cancelable (most user-input events are; scroll and focus are not).
| Event | Default action | Typical reason to prevent it |
|---|---|---|
click on <a href> | follow the link | a single-page app handles navigation itself |
submit on <form> | send the form and load a new page | send it with fetch() instead |
click on a checkbox | toggle checked | rarely a good idea (see the tip below) |
keydown in a text field | type the character | input masks, keyboard shortcuts |
wheel / touchmove | scroll | custom zoom, a map, a carousel |
contextmenu | open the right-click menu | a custom menu |
Try each one. The left switch decides whether its listener calls preventDefault():
Two details the lab shows that surprise people:
- The checkbox flips first, then flips back. Inside the click listener,
checkedalready has the new value. If you callpreventDefault(), the browser puts it back after the listeners finish. Our measured log:inside the listener checked=true; after the event checked=false. - Pressing Enter in a form field clicks the submit button. We measured the order:
clickon the submit button first, thensubmiton the form, withevent.submitter(the button that sent the form) pointing at that button. So handlesubmiton the form, notclickon the button, or you'll miss Enter.
Why stopPropagation() is usually the wrong fix
Here is bug #2 from §01. A dropdown closes when you click outside it, using one listener on document. But a click inside the menu bubbles up to document too, so the menu closes when you click its own search box.
The tempting patch is stopPropagation() inside the menu. It works, but it hides those clicks from every listener further up: analytics, other "click outside" handlers, a parent component's logic. The real fix is to ask the right question in the document listener: "was this click inside the menu?" Compare the three versions:
Click any empty space in this box to test an outside click.
document has seen 0 clicks in this demodocument.addEventListener("click", (e) => {
if (!toggleButton.contains(e.target)) close(); // any other click closes
});// menu = the wrapper around BOTH the toggle button and the panel.
// If it were only the panel, the click that OPENS the menu would also
// reach this listener and close it again straight away.
const menu = document.querySelector("#filter-menu");
document.addEventListener("click", (event) => {
if (!menu.contains(event.target)) closeMenu(); // ← only clicks outside the menu
});Event delegation: one listener for many children
event.target.closest(…) to work out which row was clicked.That's Event delegation. Here's the code for a to-do list where each row has a ★ and a ✕ button:
<ul id="todos">
<li data-id="1"><span>Write the report</span>
<button data-action="star">★</button>
<button data-action="delete">✕</button>
</li>
<!-- …and 9,999 more rows -->
</ul>const list = document.querySelector("#todos");
list.addEventListener("click", (event) => { // ← the only listener
const button = event.target.closest("button[data-action]");
if (!button || !list.contains(button)) return; // not on an action button: ignore
// (contains: closest() could climb past the list)
const id = button.closest("li").dataset.id; // which row?
if (button.dataset.action === "delete") deleteTodo(id);
if (button.dataset.action === "star") toggleStar(id);
});Why closest() and not just event.target? Because target is the deepest element that was hit. If the button contained an icon <svg>, a click on the icon makes target the <svg> (or even a <path> inside it), not the button. closest() climbs from wherever the click landed to the element you care about. And currentTarget is always the <ul>, the element the listener is on. Click around and watch all three:
- Write the report
- Review a pull request
- Book flights
Notice that rows you add later work straight away. With per-row listeners, every new row needs its own addEventListener, and every deleted row should have its listener removed. With delegation, the list's single listener covers rows that don't exist yet.
What it saves, measured
We built a 10,000-row list in headless Chromium and attached click handlers three ways:
- One arrow function per row is the expensive one: each arrow is a new function object plus a Closure holding that row's variables, about 70 bytes of JS heap per row here, and it took ~8 ms to attach.
- One shared function on every row costs almost no JS memory, but still makes 10,000 entries in the browser's internal listener lists, and still took ~3.5 ms.
- One delegated listener costs nothing measurable either way.
When delegation doesn't fit
- Events that don't bubble (
focus,mouseenter,loadon images…) never reach the parent. Use their bubbling twins where they exist (§06). - High-frequency events like
pointermoveon a whole page: a delegated listener runs for every move anywhere inside, then filters. That's fine when the filtering is cheap; profile it if it isn't. - Something below calls
stopPropagation(): the event never reaches your listener. Another reason to avoid it (§04).
Not every event bubbles
focusin for focus, mouseover for mouseenter.Every event object has a bubbles property. When it's false, the event still goes through the capture phase and the target, but skips the trip back up. So a normal (bubble) listener on a parent never runs. Here are the ones that catch people out:
| Doesn't bubble | Bubbling twin | Notes |
|---|---|---|
focus, blur | focusin, focusout | "Does any field in this form have focus?" needs the twins |
mouseenter, mouseleave | mouseover, mouseout | The twins also fire when you cross into a child, which you usually don't want |
scroll on an element | none | scroll on the document does reach window; on an inner scroll box it stays there |
load, error on <img>, <script> | none | listen on the element, or use capture on a parent |
Move the pointer over the card and its child, and click between the two fields. Every listener here is on the outer element:
We measured one path on a bare test page (enter the card, move into the child, back out to the card, then leave): 3 mouseover (plus 3 mouseout) but only 1 mouseenter (plus 1 mouseleave). Here is the full log, listeners on the card only:
mouseover:card mouseenter:card ← pointer enters the card
mouseout:card mouseover:child ← crosses into the child
mouseout:child mouseover:card ← back out to the card
mouseout:card mouseleave:card ← leavesThe mouseover with target=child bubbled up from the child, and moving between card and child produced a mouseout and a mouseover each time, although the pointer never left the card. The widget's counters run higher if your path crosses the child more often. For the form: a listener for focus on the form never ran, while focusin ran once per field.
Shadow DOM: the event pretends to come from the host
Some elements carry a private sub-tree, the Shadow DOM. Web components (your own custom HTML tags, like <date-picker>, defined in JavaScript) use it, and so do some built-in controls (the play button inside <video> is one). Its insides are hidden from the rest of the page, and events respect that.
When a click happens inside a shadow tree and travels out into the page, the browser retargets it: outside the shadow tree, event.target becomes the host element, so page code never sees the private insides. Measured:
inside shadow root: target=#inside
document: target=div#host; composedPath()[0]=#insideWhether an event may leave a shadow tree at all is the composed flag. Built-in UI events like click, keydown and input are composed. Events you create yourself are not, unless you say so:
// Inside a web component's shadow tree:
button.dispatchEvent(new CustomEvent("ping", { bubbles: true }));
// → heard inside the shadow root only
button.dispatchEvent(new CustomEvent("ping", { bubbles: true, composed: true }));
// → heard inside the shadow root AND on document (measured)Pointer, keyboard and form events in practice
event.key for "which character", event.code for "which physical key", input for every edit, change for the committed value, and submit on the form.Open the inspector and try things while you read. Everything in it is a real listener logging real events:
Pointer events: one API for mouse, touch and pen
Phones send touch events, laptops send mouse events, tablets send pen events. Pointer events (pointerdown, pointermove, pointerup, pointercancel) cover all three, and event.pointerType tells you which it was. The browser still sends the older mouse and touch events too, for old code. Measured order for one click and one tap:
mouse click: pointerdown(mouse) mousedown pointerup(mouse) mouseup click
finger tap: pointerdown(touch) touchstart pointerup(touch) touchend mouseover mousedown mouseup clickSo a tap produces a click too. That's why handling click alone works for buttons on every device, and why listening to both touchstart and mousedown for the same action runs your code twice on a phone. If you need press/drag/release, use pointer events and only pointer events.
handle.addEventListener("pointerdown", (event) => {
handle.setPointerCapture(event.pointerId); // ← keep getting moves even if the pointer leaves the handle
startDrag(event.clientX, event.clientY);
});
handle.addEventListener("pointermove", (event) => {
if (!handle.hasPointerCapture(event.pointerId)) return; // just hovering, not dragging
moveDrag(event.clientX, event.clientY);
});
handle.addEventListener("pointerup", endDrag);
handle.addEventListener("pointercancel", endDrag); // the browser took over (e.g. started scrolling)Keyboard: key versus code
| Field | Meaning | Shift + A | On a French AZERTY keyboard, same key |
|---|---|---|---|
event.key | the character (or key name) it produces | "A" | "Q" |
event.code | the physical key position, named as on a US keyboard | "KeyA" | "KeyA" |
We measured the US-layout values in Chromium: Shift+KeyA gave key="A", code="KeyA", and Shift+Digit1 gave key="!" but code="Digit1". (The AZERTY column follows from where that layout puts its letters; we didn't measure it.) Rule: use key for shortcuts named by letter ("press ? for help", Escape, Enter) and code for position-based controls (WASD in a game). Avoid the old keyCode: it's deprecated and its numbers vary by browser and layout.
IME composition: when one character takes many keys
People typing Japanese, Chinese, Korean and other languages use an IME: they type several keys, pick a suggestion, then commit. While they're choosing, the field shows a draft, and the browser fires composition events. Measured, typing に → にほ → 日本:
compositionstart data=""
compositionupdate data="に" input data="に"
compositionupdate data="にほ" input data="にほ"
compositionupdate data="日本" input data="日本"
compositionend data="日本"The input events during composition carry draft text. If you run a search or validation on every input, you'll search for half-typed words. And if you submit on Enter in keydown, you'll submit while the user is just confirming their choice in the IME. Check event.isComposing first:
input.addEventListener("keydown", (event) => {
if (event.isComposing) return; // ← the IME is using this Enter, not us
if (event.key === "Enter") send();
});Safari has a reported quirk here: the Enter that commits a composition can arrive after compositionend with isComposing already false (we couldn't test it; only Chromium is installed here). If you support Safari IME users, also ignore an Enter that comes right after a compositionend, or test with a real Japanese keyboard.
input vs change, and form submit
inputfires on every edit: each keystroke, paste, cut, or IME update. Use it for live feedback (character counters, live search with a debounce, §09).changefires when the value is committed: for a text field, when it loses focus or you press Enter (measured:changeafter Enter); for a checkbox, select or radio, right away.submitfires on the<form>, whether the user clicked the button or pressed Enter in a field. Handle it there, callevent.preventDefault()if you send it withfetch(), and read the values withnew FormData(form).
form.addEventListener("submit", async (event) => {
event.preventDefault(); // ← stay on this page
const data = new FormData(form); // every named field
const res = await fetch("/api/search", { method: "POST", body: data });
if (!res.ok) showError(`Search failed: ${res.status}`); // fetch doesn't throw on 404/500
});When handlers run: tasks, frames and passive listeners
requestAnimationFrame, and mark scroll and touch listeners passive.Listeners are tasks on the main thread
Everything in this topic runs on one thread, the Main thread, which also lays out and paints the page. When you click, the browser puts a task in a queue; the Event loop picks it up and dispatches the event, calling your listeners one after another. How the loop picks tasks and when microtasks run is covered in Async / Await §07. Two consequences matter here:
- A slow listener freezes the page. While it runs, nothing repaints and no other click is handled. Any main-thread job over 50 ms is called a Long task. The time from a click to the next painted frame is what Google's INP metric measures (more in Web Performance).
- Microtasks run between listeners, but only for real input. Microtasks run whenever the call stack is empty. During a real click, the stack is empty between one listener and the next. During
el.click()from your script, it isn't: your script is still on the stack.
Step through it:
1btn.addEventListener("click", () => {2 Promise.resolve().then(() => log("micro 1"));3 log("listener 1");4});5btn.addEventListener("click", () => {6 Promise.resolve().then(() => log("micro 2"));7 log("listener 2");8});910// Case A: a person clicks the button.11// Case B: a script does it:12btn.click();13log("after click()");
We logged both orders in headless Chromium: real mouse click → listener 1, microtask 1, listener 2, microtask 2; btn.click() → listener 1, listener 2, after click(), microtask 1, microtask 2.
requestAnimationFrame: the right moment for visual changes
The screen is redrawn in frames, usually 60 per second (90 or 120 on many newer phones and laptops), so at 60 Hz each frame has about 16.7 ms: the Frame budget. (How a frame is built is in How Browsers Render.) requestAnimationFrame asks the browser to call you just before the next frame is drawn. We counted 61 callbacks in one second in headless Chromium, one per frame.
let latestX = 0;
let scheduled = false;
window.addEventListener("pointermove", (event) => {
latestX = event.clientX; // just remember the newest value (cheap)
if (scheduled) return; // a frame is already booked
scheduled = true;
requestAnimationFrame(() => { // ← once per frame, right before painting
scheduled = false;
dot.style.transform = `translateX(${latestX}px)`;
});
});Why not update the DOM directly in the listener? Some input events can arrive more often than frames are drawn, and every DOM write in between is wasted work nobody sees. Writing in rAF does at most one update per frame, always with the newest value. Chromium already lines up continuous events like pointermove and wheel with frames on most devices, so this pattern costs nothing and protects you on the ones where it doesn't. (event.getCoalescedEvents() gives you the in-between pointer positions if you're drawing a smooth line.)
Passive listeners and smooth scrolling
When you swipe or spin the wheel, the browser wants to scroll immediately, ideally on a separate thread, the compositor (How Browsers Render has the details). But if a touchstart, touchmove or wheel listener might call preventDefault() to cancel the scroll, the browser has to wait for your JavaScript first. If the main thread is busy, scrolling stutters.
A passive listener is your promise that you won't cancel:
window.addEventListener("touchmove", trackSwipe, { passive: true }); // the browser scrolls without waitingIf you break the promise, the call is ignored and Chromium logs a console error. Both measured:
preventDefault() in a { passive: true } listener → defaultPrevented: false
[error] Unable to preventDefault inside passive event listener invocation.Browsers went one step further: wheel, touchstart and touchmove listeners on window, document or <body> are passive by default in Chromium and Firefox (Safari does this at least for the touch events; we only measured Chromium), because so many sites added them without needing to cancel. We added a plain document.addEventListener("wheel", e => e.preventDefault()) and spun the wheel: cancelable=false, the page scrolled 300 px anyway, and Chromium printed "Unable to preventDefault inside passive event listener due to target being treated as passive." If you really need to block scrolling (a map, a custom carousel), attach the listener to that element with { passive: false }.
Debounce and throttle
Some events arrive in floods: typing fires input per keystroke, dragging fires dozens of pointermove per second, resizing a window fires resize continuously. Often you don't need to react to every one.
Move your pointer over the pad below, stop for a moment, then move again. Each tick is one call. Try the three window sizes:
What you should see: raw and rAF lanes look almost the same. That's because Chromium already delivers pointermove at most about once per frame (§08). The Simulate button is different: it calls the handler every 12 ms, faster than the screen refreshes, so there the rAF lane is visibly sparser than raw (we saw about 100 raw ticks vs 63 rAF ticks). That gap is rAF doing its job: extra calls inside one frame are merged. Throttle ticks at a steady rhythm while you move. Debounce stays silent while you move and ticks once, a moment after you stop.
The code
function debounce(fn, ms) {
let timer; // shared by every call below
return (...args) => {
clearTimeout(timer); // ← cancel the previous plan
timer = setTimeout(() => fn(...args), ms);
};
}
const search = debounce((q) => fetchResults(q), 300);
input.addEventListener("input", (e) => search(e.target.value));We ran both in Node 22 with 100 calls, one every 10 ms (a one-second burst), and a 300 ms window:
debounce: call 100 @ 1308 ms ← 1 run, 300 ms after the burst ended
throttle: 1 @ 11, 30 @ 312, 60 @ 612, 90 @ 913, 100 @ 1214 ← every 300 ms, plus the last oneThrottle's last run (call 100) is the trailing call: without it, the final position of a drag or scroll would be lost.
Which one when?
| Situation | Use | Why |
|---|---|---|
| Search as you type, autosave, validate a field | debounce (200–500 ms) | You want the final value, once |
| Infinite scroll check, analytics on scroll, window resize logic | throttle (100–250 ms) | Steady updates during the action |
| Moving, resizing, drawing anything on screen | rAF | Once per frame is exactly as fast as the eye can see |
React's event system
onClick on the button. It puts one listener per event type on the root container (the element you passed to createRoot, usually <div id="root">) and runs your handlers itself, walking your component tree like the browser walks the DOM.You've written <button onClick={save}> many times. Here's what actually happens in React 17 and later (we checked React 19.3):
- When you call
createRoot(container), React adds its own listeners tocontainer: both a capture and a bubble listener for almost every event type. We counted about 140 listeners on the root in React 19.3 (the exact number varies a little between builds). - Your button gets no listener of its own for
onClick. - When a real click bubbles up to the root, React looks at
event.target, finds the components on the way up, and calls theironClickprops in order. That's Event delegation (§05), done for you.
Your handler gets a Synthetic event: a wrapper with the familiar fields (target, currentTarget, preventDefault(), stopPropagation()) and the real browser event in e.nativeEvent. Click the button below, then try the two checkboxes:
Here's the measured order, with nothing stopped:
1. native · document (capture)
2. React · <div onClickCapture> ← React's capture listener on the root
3. native · <button> addEventListener ← the button really has this listener
4. React · <button onClick> ← React's bubble listener on the root
5. React · <div onClick>
6. native · document (bubble)Each line follows from the phases in §03 once you know React's listeners sit on the root:
onClickCaptureruns during the capture phase, when the event passes the root on its way down (step 2), so before the button's own listener.- A native listener on the button runs before the button's React
onClick(step 3 before 4): the event reaches the button before it bubbles up to the root. - React's
e.stopPropagation()stops both trees. React stops its own walk and calls the nativestopPropagation(), sodocumentlisteners (step 6) don't run. Measured: lines 5 and 6 disappeared. - A native
stopPropagation()on the button hides the click from React completely. The event never reaches the root, so no ReactonClickabove that point runs (measured: the log stops at line 3). This is how a third-party widget can "eat" clicks in your React app.
Native listeners in React components
Sometimes you need a real listener: on window (resize, keyboard shortcuts), on document (click outside), or with options React doesn't expose (passive: false). Add it in an effect and remove it in the cleanup:
import { useEffect } from "react";
function useKeyboardShortcut(key, onPress) {
useEffect(() => {
const controller = new AbortController();
window.addEventListener("keydown", (event) => {
if (event.key === key && !event.isComposing) onPress(event);
}, { signal: controller.signal });
return () => controller.abort(); // ← React calls this when the component goes away
}, [key, onPress]); // wrap onPress in useCallback, or this re-subscribes on every render
}In development, React's StrictMode sets every effect up, cleans it up, and sets it up again. If your cleanup forgets to remove the listener, you'll see the handler run twice: bug #1 from §01 again. Why React re-renders and what StrictMode does are covered in How React Works.
Production: leaks, custom events, postMessage, accessibility
1. Listener leaks keep whole components alive
A listener on a long-lived object (window, document, a global store) holds on to its function, and the function's Closure holds on to whatever it uses, often the component's own DOM element. Remove the element from the page and forget the listener, and the garbage collector can't free any of it: the element is still reachable from window. That's a Memory leak, the classic one in single-page apps, where the page itself is never reloaded to wipe the slate clean. The GC topic explains reachability in depth.
We measured it: 20 "page visits", each creating 50 cards that add a resize listener on window and then removing the cards from the page.
// Leaky: nothing ever removes the listener
function mountCard(card) {
window.addEventListener("resize", () => {
card.style.width = innerWidth / 3 + "px"; // ← keeps `card` alive
});
}
for (const card of cards) mountCard(card);
// "leave the page":
app.replaceChildren(); // cards gone from the page,
// still reachable from window// Fixed: one controller per page visit
function mountCard(card, signal) {
window.addEventListener("resize", () => {
card.style.width = innerWidth / 3 + "px";
}, { signal }); // ← removable
}
const controller = new AbortController();
for (const card of cards) mountCard(card, controller.signal);
// "leave the page":
app.replaceChildren();
controller.abort(); // ← removes all 50 listeners| After 1,000 cards were removed (forced GC) | DOM nodes still alive | Listeners on window |
|---|---|---|
| Leaky (no cleanup) | 7,005 | 1,000 |
With { signal } + controller.abort() on unmount | 5 | 0 |
Measured on this machine (headless Chromium 153) with Memory.getDOMCounters, a DOM-node counter that Chrome exposes to test scripts through its DevTools protocol. The 5 nodes left in the fixed version are the test page's own. Every removed card, with its heading, paragraph and button, stayed in memory. In DevTools, a heap snapshot filtered for "Detached" shows exactly these: elements no longer in the page but still reachable.
2. Custom events: components that talk without importing each other
Any element can fire events you invent, using CustomEvent. A product card can announce "added to cart" and the header badge can listen on document, without either importing the other. That's the observer pattern (Behavioral patterns) built into the DOM.
// The card announces:
const ok = card.dispatchEvent(new CustomEvent("cart:add", {
detail: { sku: 42, qty: 1 }, // ← your data goes in `detail`
bubbles: true, // default is false: only the card itself would hear it
cancelable: true, // let listeners veto it with preventDefault()
}));
if (!ok) showMessage("Can't add that right now"); // a listener called preventDefault()
// The header listens, anywhere above:
document.addEventListener("cart:add", (event) => {
badge.textContent = Number(badge.textContent) + event.detail.qty;
});Things we measured that you need to know:
bubblesdefaults tofalsefor events you make (the parent heard nothing until we passedbubbles: true). Built-in clicks bubble; your events don't unless you ask.dispatchEventis synchronous. All listeners run beforedispatchEventreturns, inside your code. Measured order:before dispatch → child handler → after dispatch. It's a function call through the DOM, not a message queue.- It returns
falseif a listener calledpreventDefault()on acancelableevent. That's your veto mechanism. - An exception in one listener doesn't stop the others. We threw an error in listener 1: listener 2 still ran,
click()returned normally, and the error went towindow'serrorevent. Good for robustness, but errors in listeners never reach the code that dispatched the event, so make sure they reach your error reporting.
3. Cross-frame messages: always check the origin
A page and an iframe from a different origin can't touch each other's DOM (CORS & CSRF explains why). They talk with postMessage instead, and the receiver gets a message event. Any window that can get a reference to yours (an iframe you embed, a popup, the page that opened you) can send you messages. So the listener must check who sent each one.
We embedded two iframes, a real widget and an "evil" one. Both sent a "resize" message; this is what the parent's listener received:
message from https://widget.test: {"type":"resize","height":320} source is widget frame: true
message from https://evil.test: {"type":"resize","height":99999} source is widget frame: false// Receiving: check the sender before trusting anything
window.addEventListener("message", (event) => {
if (event.origin !== "https://widget.example.com") return; // ← who sent it
if (event.source !== widgetFrame.contentWindow) return; // ← and from which frame
if (event.data?.type === "resize") widgetFrame.style.height = `${Number(event.data.height)}px`;
});
// Sending: name the receiver's origin, never "*" for anything private
widgetFrame.contentWindow.postMessage({ type: "theme", value: "dark" }, "https://widget.example.com");Why the sender names an origin
The second argument of postMessage protects your data. A frame's address can change after you embedded it: a compromised ad, an open redirect, or the frame's own bug can send it to another site, and your widgetFrame.contentWindow reference now points at that site. If you send a secret with "*", whoever is in the frame gets it.
We tested exactly that: the parent sends a token to a frame that started on widget.test, then the frame navigates itself to evil.test, and the parent sends again.
const frame = document.querySelector("#widget").contentWindow;
frame.postMessage("token=abc123", "https://widget.test"); // ← delivered only if the frame IS widget.test
frame.postMessage("token=abc123", "*"); // ← delivered to whoever is in the frame nowframe on widget.test, target "https://widget.test" → widget got: token=abc123
frame on evil.test, target "https://widget.test" → not delivered; console:
Failed to execute 'postMessage' on 'DOMWindow': The target origin provided
('https://widget.test') does not match the recipient window's origin ('https://evil.test').
frame on evil.test, target "*" → EVIL got: token=abc123So: check event.origin when you receive, and name the exact origin when you send. Message data that ends up in innerHTML is a classic DOM-based XSS hole, so treat event.data like any other user input.
4. Accessibility: every click needs a keyboard path
Many people never use a mouse: keyboard users, Screen reader users, switch-device users. A <div> with a click listener can't be reached with Tab and doesn't react to Enter or Space. A <button> does all of that for free, and it fires click for Enter and Space too, so one click listener covers everyone.
Both snippets below use inline onclick only to stay short; in real code attach the listener with addEventListener as in §02.
<!-- ✗ mouse-only: no focus, no Enter/Space, a screen reader says "group" or nothing -->
<div class="btn" onclick="save()">Save</div><!-- ✓ focusable, Enter and Space fire click, announced as "Save, button" -->
<button type="button" onclick="save()">Save</button>Keep that parity everywhere: hover menus also open on focus, drag-and-drop has a keyboard alternative, and custom keyboard shortcuts skip key presses while the user is typing in a field. The Accessibility topic covers focus management and ARIA properly.
Pitfalls gallery
Eight bugs that show up in real code reviews, each with the fix. Every "measured" note comes from headless Chromium 153.
The click handler that runs twice: a label around an input
Clicking a <label>'s text makes the browser send a second click to its input (that's how labels toggle checkboxes). That second click bubbles back up through the label. Measured: one physical click, two calls: target=label, then target=input.
label.addEventListener("click", () => {
toggleSetting(); // runs twice: undone again!
});// listen for the state change instead:
checkbox.addEventListener("change", () => {
toggleSetting(checkbox.checked); // once per real change
});The click handler that runs twice: setup code that runs again
Each () => … is a new function, so the browser can't spot the duplicate (§02). Any setup that can run twice (a re-render, React StrictMode, a route you visit again) stacks another listener.
function setup() {
button.addEventListener("click", () => save());
}
setup(); setup(); // two listeners → save() twicelet controller;
function setup() {
controller?.abort(); // remove the old ones first
controller = new AbortController();
button.addEventListener("click", () => save(), { signal: controller.signal });
}removeEventListener that removes nothing
removeEventListener matches on type + the same function + the same capture flag (§02). .bind(this) also creates a new function each time you call it.
el.addEventListener("scroll", this.onScroll.bind(this), true);
// later:
el.removeEventListener("scroll", this.onScroll.bind(this)); // new function, wrong flagthis.onScroll = this.onScroll.bind(this); // bind ONCE, keep it
el.addEventListener("scroll", this.onScroll, true);
el.removeEventListener("scroll", this.onScroll, true);preventDefault() after an await
Listeners run synchronously. Once your listener hits its first await, it returns to the browser, dispatch finishes, and the default action (following a link, submitting a form) happens. A preventDefault() that runs later is too late. Measured: with await before preventDefault(), Chromium went ahead and loaded the form's result page.
form.addEventListener("submit", async (e) => {
const ok = await validate(); // the listener returns here…
if (!ok) e.preventDefault(); // …so this is too late: the form already submitted
});form.addEventListener("submit", async (e) => {
e.preventDefault(); // decide synchronously, first
if (await validate()) await send(new FormData(form));
});Reading event.currentTarget later
currentTarget is only set while a listener runs. Afterwards the browser sets it back to null. Measured: currentTarget read inside a setTimeout was null.
list.addEventListener("click", (e) => {
setTimeout(() => e.currentTarget.classList.add("seen"), 100);
// TypeError: Cannot read properties of null (reading 'classList')
});list.addEventListener("click", (e) => {
const el = e.currentTarget; // copy it now
setTimeout(() => el.classList.add("seen"), 100);
});this in an arrow-function listener
With a regular function listener, this is the element (same as currentTarget). Arrow functions don't have their own this, so inside one it's whatever this was outside. Code copied from an old function example to an arrow silently changes meaning. Use event.currentTarget in both cases; it can't change under you.
button.addEventListener("click", function () { this.disabled = true; }); // this = button
button.addEventListener("click", () => { this.disabled = true; }); // this = outer this (often undefined)
button.addEventListener("click", (e) => { e.currentTarget.disabled = true; }); // ✓ clear and safemouseover for hover effects
mouseover bubbles, so it fires again each time the pointer crosses into a child (§06: 3 mouseover vs 1 mouseenter on one path through a card with one child). A tooltip that re-opens or flickers is usually this. Use mouseenter/mouseleave, or better, pointer events: pointerenter/pointerleave. For pure styling, CSS :hover needs no JavaScript at all.
Heavy work in a scroll or input listener
Each listener runs on the main thread before the next frame can paint (§08). Sorting 5,000 rows on every keystroke, or measuring layout on every scroll, makes typing and scrolling stutter. Debounce the expensive part, write visual changes in rAF, and mark scroll/touch listeners passive (§08–§09).
input.addEventListener("input", debounce((e) => renderResults(filter(e.target.value)), 200));
window.addEventListener("scroll", onScroll, { passive: true });Check yourself: predict what happens
Commit to an answer before you click. Every answer was checked in headless Chromium, and each explanation points to the section to re-read.
Click the <button> inside #card.
card.addEventListener("click", () => log("card bubble"));
btn.addEventListener("click", () => log("btn"));
card.addEventListener("click", () => log("card capture"), { capture: true });A button has two click listeners. The first calls e.stopPropagation(). Does the second one run?
<label id="lbl"><input type="checkbox"> Remember me</label> and lbl.addEventListener("click", h). You click the words “Remember me”. How many times does h run?
Each listener runs Promise.resolve().then(() => log("m" + n)) and then log("L" + n). A script calls btn.click(); log("after").
You want one listener on a <form> that runs whenever any of its inputs gets focus.
A button has a React onClick and a native addEventListener("click"). A user clicks it.
document.addEventListener("wheel", (e) => e.preventDefault()) with no options. You spin the mouse wheel.
Cheat sheet
{ capture: true } runs on the way down.target = where it happened (deepest element). currentTarget = where this listener is attached. closest() climbs from target to what you care about.stopPropagation ends the journey (other listeners on this element still run). preventDefault cancels the browser's own action. Prefer contains() checks over stopping.AbortController per component; pass signal to every listener and fetch; abort() on unmount. Forgotten listeners leak whole components.{ passive: true } lets the browser scroll without waiting. On window/document/body, wheel and touch listeners are passive by default.<button>: Enter and Space fire click. Check isComposing before acting on Enter.| You want… | Use |
|---|---|
| Run once, then stop listening | { once: true } |
| Remove many listeners at once | { signal } + controller.abort() |
| Handle clicks on many rows | one listener on the parent + event.target.closest("li") |
| Know if focus is anywhere inside a form | focusin / focusout (they bubble) |
| Hover without child flicker | pointerenter / pointerleave (or CSS :hover) |
| Press / drag on mouse, touch and pen | pointerdown / pointermove / pointerup + setPointerCapture |
| Every edit vs the committed value | input vs change (React's onChange = input) |
| Handle a form, including Enter | submit on the <form> + preventDefault() + FormData |
| Shortcut by letter vs by key position | event.key vs event.code |
| Click outside to close | document listener + !menu.contains(event.target) |
| Your own announcement | new CustomEvent(type, { detail, bubbles: true }) |
| Talk to an iframe | postMessage(data, exactOrigin); check event.origin on receipt |