A click on a button inside a list sounds simple. But the browser does not just "fire" an event on the element — it passes through the entire DOM tree in three phases. Understanding this mechanism lets you write handlers efficiently, avoid unnecessary listeners, and not be surprised when stopPropagation breaks someone else's code.
Three phases: capture → target → bubble
When a user clicks an element, the browser creates an event and sends it through the tree twice.
- Capture — the event travels top-down:
document→html→body→ … → the target element. Capture-phase handlers are called first. - Target — the event has reached the target element. Both handler types (capture and bubble) are invoked here.
- Bubble — the event travels bottom-up: target element → … →
body→html→document. Most handlers listen to this phase.
Most events bubble. Exceptions: focus, blur, scroll, load — these do not bubble (there are focusin/focusout alternatives that do).
addEventListener and options
element.addEventListener("click", handler); // bubble phase (default)
element.addEventListener("click", handler, true); // capture phase
element.addEventListener("click", handler, {
capture: false, // bubble
once: true, // fires once, then removes itself
passive: true, // will not call preventDefault — browser does not wait
});
once: true is a convenient alternative to manually calling removeEventListener for one-shot handlers.
event.target and event.currentTarget
event.target— the element on which the event occurred (what the user clicked).event.currentTarget— the element on which the handler is registered.
document.querySelector("ul").addEventListener("click", (e) => {
console.log(e.target); // <li> or <button> — what was clicked
console.log(e.currentTarget); // <ul> — where the handler is attached
});
As the event bubbles, currentTarget changes (each handler on the path sees itself), while target stays the same — it is always the originating element.
stopPropagation and preventDefault
event.stopPropagation() stops the event from propagating further — neither up nor down the tree. Use with caution: higher-level code may depend on receiving the event. Modals, analytics, component libraries often listen for events at the document level.
button.addEventListener("click", (e) => {
e.stopPropagation(); // click will not bubble to parents
doSomething();
});
event.preventDefault() cancels the browser's default action (following a link, submitting a form, scrolling with the wheel). It does not stop propagation.
link.addEventListener("click", (e) => {
e.preventDefault(); // browser will not navigate to href
handleNavigation(e.currentTarget.href);
});
stopPropagation is rarely needed — preventDefault is usually sufficient. Uncontrolled use of stopPropagation is a popular source of hard-to-trace bugs in complex UIs.
Delegation: one handler on the container
Instead of attaching a handler to every child element, attach one to the parent and inspect event.target inside.
Without delegation — a handler on every button:
document.querySelectorAll(".item button").forEach((btn) => {
btn.addEventListener("click", handleDelete);
});
Problem: when new elements are added dynamically, handlers must be re-attached.
With delegation — one handler on the list:
document.querySelector(".list").addEventListener("click", (e) => {
const btn = e.target.closest("[data-action='delete']");
if (!btn) return;
const id = btn.closest(".item").dataset.id;
handleDelete(id);
});
Element.closest(selector) walks up the tree and finds the nearest ancestor (or the element itself) matching the selector. If the click landed on an icon inside the button, e.target points to the icon, and closest("[data-action]") finds the button itself.
Delegation has two advantages: fewer listeners in memory and automatic support for dynamically added elements.
passive and scroll performance
The browser cannot know in advance whether a handler will call preventDefault() on touchstart or wheel. Until the handler completes, the browser is forced to wait before starting the scroll. This introduces a delay even if the handler is empty.
passive: true tells the browser: "I will not call preventDefault" — the browser starts scrolling immediately in parallel with your code.
document.addEventListener("touchstart", onTouch, { passive: true });
document.addEventListener("wheel", onWheel, { passive: true });
If you mark a handler passive: true and call preventDefault() anyway — the browser ignores the call and prints a warning to the console.
Custom Events
Sometimes you need to communicate between unrelated parts of the application without threading callbacks down through props.
// Create and dispatch
const event = new CustomEvent("user:logout", {
bubbles: true, // bubbles up the tree
composed: true, // crosses shadow DOM boundaries
detail: { userId: 42 },
});
document.dispatchEvent(event);
// Subscribe
document.addEventListener("user:logout", (e) => {
console.log("User logged out:", e.detail.userId);
});
bubbles: true lets you listen on any ancestor — the same delegation pattern, but for custom events.
How this works in React
React does not attach handlers directly to DOM elements. Starting with React 17, all handlers are registered on the root container (root). This is React's own form of delegation.
<button onClick={handleClick}>Click</button>
// React attaches the handler to root, not to <button>
Synthetic events. React wraps the native event in a SyntheticEvent — an object with the same interface but cross-browser compatible. Before React 17, events were pooled and reset after the handler (so you could not store event in a setTimeout). Since React 17 pooling is removed — event lives as long as needed.
e.stopPropagation() in React stops bubbling through the React component tree. A native addEventListener on the same element may fire earlier or later depending on where it is registered — keep this in mind when mixing React and native handlers.
In short
- An event goes through three phases: capture (top-down) → target → bubble (bottom-up).
addEventListenerlistens to the bubble phase by default;{ capture: true }selects the capture phase.event.target— the originating element;event.currentTarget— the element with the handler.stopPropagationstops propagation; use carefully — it breaks other listeners.preventDefaultcancels the browser action, does not stop propagation.- Delegation: one handler on the container +
closest()— supports dynamic elements and saves memory. passive: trueontouchstart/wheelremoves scroll delay.- React 17+ registers handlers on the root container — its own form of delegation.
What to read next
- this: call context — why this is lost in handlers and how to fix it.
- Event loop: how it works and where it bites — how events enter the task queue.
- Rendering performance — passive listeners and their impact on Core Web Vitals.
- Error handling — how to catch errors in asynchronous event handlers.