JAVASCRIPT / THE BROWSER: DOM, EVENTS, AND STORAGE
Event objects, bubbling, and delegation
Trace a click from window down to the target and back, read target vs currentTarget, and handle a whole list with one delegated listener.
What you will learn
- e.target is where the event started; e.currentTarget is the node handling it now
- Attach one listener to a container and identify the row with e.target.closest()
- Tell preventDefault (cancels the default action) from stopPropagation (ends the path)
- Check e.bubbles before delegating; use focusin/focusout instead of focus/blur
Understanding Event objects, bubbling, and delegation
When an interaction happens the browser creates one object describing it and hands that same object to every listener that runs. Alongside type and the interaction details (clientX, key, button) it carries two properties about position in the DOM: e.target is the deepest element the event originated on and never changes during a dispatch, while e.currentTarget is the node whose listener is executing at this instant. That difference causes most delegation bugs, because a click on the span inside a button has the span as target even though your listener sits on the button.
Before any listener runs, the browser builds the propagation path: window, document, html, body, and every ancestor down to the target. It then traverses that path in three phases, capturing downward, firing at the target, then bubbling back up, which is what eventPhase reports as 1, 2 or 3. Ordering therefore comes from the path, not from the order you added listeners. Bubbling exists so an ancestor can react to anything that happens inside it, and capture exists so an ancestor can inspect or veto an event before the target ever sees it. Not everything bubbles: focus, blur, mouseenter and mouseleave stop at the target, and e.bubbles tells you which kind you have.
Delegation turns bubbling into a strategy. Instead of one listener per row, put a single listener on a container that outlives its children and work out what was hit from e.target. e.target.closest(selector) is the standard tool because it climbs from the deepest element upward, which is exactly the step that converts "the span inside the button" into "the button". Rows inserted or replaced later work with no rebinding, and one function sits in memory instead of hundreds. The costs are that you must handle clicks landing on the container itself, where closest returns null, and that anything calling stopPropagation between the target and your container makes the handler go silent.
// Run this on a blank page (about:blank) in the browser console.
const list = document.createElement('ul');
list.id = 'menu';
list.innerHTML = '<li id="save">Save</li><li id="quit">Quit</li>';
document.body.append(list);
function report(e) {
const here = e.currentTarget.id || e.currentTarget.nodeName;
console.log(here, '| target:', e.target.id, '| phase:', e.eventPhase, '| bubbles:', e.bubbles);
}
document.body.addEventListener('click', report); // added first, runs last
list.addEventListener('click', report);
document.getElementById('save').addEventListener('click', report);
document.getElementById('save').click();A single event object travels a precomputed path from the root down to e.target and back up, so one listener on an ancestor can identify and handle anything that happens inside it.
Worked examples
One listener for a growing list
A single container listener handles buttons that did not exist when it was registered, including clicks on their inner elements.
const board = document.createElement('div');
document.body.append(board);
board.addEventListener('click', (e) => {
const btn = e.target.closest('button[data-action]');
if (!btn) return; // the click landed on the container, not a button
console.log(btn.dataset.action, 'requested by', e.target.tagName);
});
function addButton(action, label) {
const btn = document.createElement('button');
btn.dataset.action = action;
const bold = document.createElement('b');
bold.textContent = label;
btn.append(bold);
board.append(btn);
return btn;
}
addButton('save', 'Save').querySelector('b').click(); // hit the inner element
addButton('undo', 'Undo').click(); // hit the button itself
board.click(); // hit the containerExample explained
Line 1The first click starts on the <b>, so e.target.tagName is B and the handler must climb to find the button.
Line 2closest walks from e.target up through its ancestors and returns the first match, or null.
Line 3The undo button was created after the listener existed and still works, because only the container is wired.
Line 4board.click() logs nothing: closest finds no button ancestor, so the guard returns early.
preventDefault is not stopPropagation
Cancelling the browser's default action leaves the propagation path completely intact.
const link = document.createElement('a');
link.href = '#docs';
link.textContent = 'Docs';
document.body.append(link);
link.addEventListener('click', (e) => {
e.preventDefault();
console.log('link handler, defaultPrevented:', e.defaultPrevented);
});
document.body.addEventListener('click', (e) => {
console.log('body still sees it, target:', e.target.textContent, '| cancelable:', e.cancelable);
});
link.click();Example explained
Line 1preventDefault cancels the browser's own reaction to the click, so the page does not jump to #docs.
Line 2It only flips e.defaultPrevented; the event keeps climbing, which is why the body listener still runs.
Line 3stopPropagation would do the opposite: body would hear nothing, but the link would still navigate.
Capture runs before the target
A capturing listener on an ancestor sees the click first and can end the dispatch before the target's own handler runs.
const panel = document.createElement('div');
document.body.append(panel);
for (const id of ['ok', 'cancel']) {
const b = document.createElement('button');
b.id = id;
b.textContent = id;
b.addEventListener('click', (e) => console.log(' target listener:', e.currentTarget.id));
panel.append(b);
}
panel.addEventListener('click', (e) => {
console.log('capture on panel, phase', e.eventPhase, 'for', e.target.id);
if (e.target.id === 'cancel') e.stopPropagation();
}, true);
panel.addEventListener('click', () => console.log(' bubble on panel'));
document.getElementById('ok').click();
document.getElementById('cancel').click();Example explained
Line 1The third argument true registers the listener for the capture phase, so it runs on the way down and eventPhase is 1.
Line 2For the ok button nothing stops the dispatch, so the same object fires at the target and again on the panel while bubbling.
Line 3For cancel, stopPropagation ends the traversal immediately, so the button's own listener is never reached.
Important notes
e.currentTarget is only meaningful while the handler is on the call stack; copy it into a variable before any await or setTimeout, otherwise you read null.
The path is computed when the event is dispatched, so if one handler removes the clicked node, later handlers still fire but e.target.closest() on the now-detached node can no longer find your container.
Common mistakes
Reading e.target.dataset.action when the button contains an icon or span: the inner element is the target, dataset.action is undefined, and the click appears to do nothing at all.
Adding e.stopPropagation() by reflex inside a widget: outer delegated handlers and click-outside-to-close logic silently stop firing, and the failure appears far away from the line that caused it.
Delegating focus, blur or mouseenter to a container: those events do not bubble, so the container listener never runs; focusin, focusout and mouseover are the bubbling equivalents.
Try it yourself
Change, predict, then run
On a blank page build a ul with three li items plus an Add button that appends new items, then wire exactly one click listener on the ul that uses e.target.closest('li') to log the clicked item's text. Confirm that items added after the listener was registered still respond, and that clicking the ul's own padding logs nothing.
Open the JavaScript workspaceCheck your understanding
A dropdown component calls e.stopPropagation() in its own click handler. A separate document-level listener that closes the dropdown when you click elsewhere stops working. What explains this?
- stopPropagation cancels the click's default action, and a cancelled event is not delivered any further
- The event's path never included document, because the click happened inside the dropdown
- The event stops being carried along the path once the dropdown's listeners finish, so document is never visited
- Two listeners for the same event type cannot coexist, so the dropdown's handler replaced the document's
Show answer
Every click gets a path from window down to the deepest element and back up; stopPropagation ends the traversal of that path after the current node's listeners run, so ancestors such as document are skipped even though they are on it. The first option is tempting because both calls sound like they stop something, but preventDefault is what cancels the browser's own reaction (navigation, submit, checkbox toggle) and it leaves propagation untouched.