Event Bubbling in JavaScript Explained with target and currentTarget
Event bubbling in JavaScript is why a click on a nested <span> can be handled by the button, the list item, the list, and the document. The sharp distinction is simple: event.target is where the event started, while event.currentTarget is the element whose listener is running right now.
That distinction is the difference between clean event delegation and a handler that works until someone wraps the button text in an icon span.
The naive expectation
This markup has a card, a button, and a span inside the button:
<article id="card">
<button id="save-button">
<span id="save-label">Save</span>
</button>
</article>
A reasonable beginner expectation is: "If I click the span, only the span gets the click." The DOM does something more useful.
const card = document.querySelector("#card");
const button = document.querySelector("#save-button");
function logEvent(event) {
console.log({
target: event.target.id,
currentTarget: event.currentTarget.id,
});
}
card.addEventListener("click", logEvent);
button.addEventListener("click", logEvent);
Click the span and the output is:
{ target: "save-label", currentTarget: "save-button" }
{ target: "save-label", currentTarget: "card" }
The event started at #save-label. Then it bubbled up to ancestors that had click listeners.
How bubbling moves through the DOM
For most everyday click handling, you can think of an event in three stages:
- Capture: the event travels from the document down toward the target.
- Target: the event reaches the element where it started.
- Bubble: the event travels back up through the ancestors.
Most addEventListener calls listen during the target and bubbling part. Capture exists, but you only opt into it when you pass { capture: true }.
document.body.addEventListener(
"click",
() => {
console.log("body capture");
},
{ capture: true },
);
document.body.addEventListener("click", () => {
console.log("body bubble");
});
You do not need capture for most app code. Bubbling is the default because it supports a useful pattern: put one listener on a stable parent and let child events reach it.
Not every event bubbles, so check the specific event when behavior feels off. Clicks, keyboard events, input events, and many form events do bubble. The event object also has a bubbles property if you need to inspect it.
target vs currentTarget
Use this table until the names stop looking like twins:
| Property | Meaning | In the card example |
|---|---|---|
event.target | The deepest element where the event started | #save-label |
event.currentTarget | The element whose listener is currently running | #save-button, then #card |
target stays the same as the event bubbles. currentTarget changes for each listener.
That means this delegated handler can be wrong:
const toolbar = document.querySelector("#toolbar");
toolbar.addEventListener("click", (event) => {
event.target.classList.add("is-active");
});
If the user clicks an icon inside a button, the icon gets the class. The button was the real action, but the event started deeper.
Use closest to climb from the target to the element that represents the action:
const toolbar = document.querySelector("#toolbar");
toolbar.addEventListener("click", (event) => {
if (!(event.target instanceof Element)) return;
const button = event.target.closest("button[data-action]");
if (!button || !toolbar.contains(button)) return;
button.classList.add("is-active");
});
That guard is not ceremony for the sake of ceremony. In browser types, event.target is an EventTarget, not always an Element, so closest is not guaranteed to exist. The check keeps the handler honest.
Event delegation uses bubbling on purpose
Event delegation means one parent listener handles many child interactions.
<ul id="lesson-list">
<li data-lesson="selectors"><span>Selectors</span></li>
<li data-lesson="events"><span>Events</span></li>
<li data-lesson="forms"><span>Forms</span></li>
</ul>
const lessonList = document.querySelector("#lesson-list");
lessonList.addEventListener("click", (event) => {
if (!(event.target instanceof Element)) return;
const item = event.target.closest("li[data-lesson]");
if (!item || !lessonList.contains(item)) return;
console.log(item.dataset.lesson);
});
Click the text, the span, or the list item. The listener still lives on the list. The event bubbles up, and the handler uses target plus closest to find the relevant item.
This is especially useful when items are added later. New children do not need their own listeners because the parent listener is already there.
currentTarget is the stable boundary
Inside a listener, currentTarget is the element you attached the listener to. That makes it a good boundary for parent-level behavior.
A modal backdrop is a classic example:
<section id="modal-backdrop" class="modal is-open">
<article id="modal-panel">
<h2>Confirm action</h2>
</article>
</section>
You want backdrop clicks to close the modal, but panel clicks to leave it open.
const backdrop = document.querySelector("#modal-backdrop");
backdrop.addEventListener("click", (event) => {
if (event.target !== event.currentTarget) return;
backdrop.classList.remove("is-open");
});
When the backdrop itself is clicked, target and currentTarget are the same element. When the panel is clicked, target is the panel or something inside it, while currentTarget is still the backdrop. That one comparison gives you the boundary.
stopPropagation is a tool, not a broom
event.stopPropagation() stops the event from continuing to later ancestors. It is useful when a nested component should handle an interaction and keep it inside that boundary.
const backdrop = document.querySelector("#modal-backdrop");
const panel = document.querySelector("#modal-panel");
backdrop.addEventListener("click", () => {
backdrop.classList.remove("is-open");
});
panel.addEventListener("click", (event) => {
event.stopPropagation();
});
That works. It is also easy to overuse. If every handler calls stopPropagation because "some parent might do something," the page turns into a set of tiny event silos. Debugging that is about as fun as reading minified stack traces in a checkout bug.
Prefer target, currentTarget, and closest when you are deciding what was clicked. Use stopPropagation when you truly want to stop ancestors from hearing the event.
Debug bubbling with a trace
When a click lands in the wrong place, attach a small trace temporarily:
document.addEventListener("click", (event) => {
if (!(event.target instanceof Element)) return;
console.log("target:", event.target);
console.log("currentTarget:", event.currentTarget);
console.log("path:", event.composedPath());
});
composedPath() shows the route the event traveled. That is often faster than staring at the DOM and guessing which wrapper swallowed the click. You can remove the trace once you understand the path.
Key Takeaways
- Event bubbling lets a child event move upward through ancestors, which makes parent-level listeners possible.
event.targetis where the event started;event.currentTargetis the element whose listener is running.- In delegated handlers, use
targetwithclosestto find the actionable child. - Use
currentTargetas the stable boundary when the parent itself owns the behavior. - Reach for
stopPropagationonly when an event should truly stop crossing a component boundary.