Lesson 02 / 24
The Event Model
The path an event follows from root to target and back from target to root; capture, target, and bubble phases, listener order, the two forms of stopping propagation, and listener identity.
Contents
The previous lesson established reading and modifying the document. What triggers a change was left open: a page does not change on its own, it changes in response to a notification the user or the browser produces. This notification is called an event.
Which node an event belongs to is clear at first glance — the button clicked, the field typed into. The real question is: does the event get announced only to that node? The answer is no, and this lesson’s subject is the defined path an event follows on the tree.
An Event Is an Object
An event is an object the browser produces and hands to the listener as an argument. Three fields are present in every event.
Type is the event’s name: click, input, submit, keydown. A listener registers
for a type; events of a different type do not call it.
Target is the node the event originated from. In a click event, this is the deepest element under the pointer — even if it looks like the row itself was clicked, the target is the button inside that row.
Current target is the node the listener is attached to. The two are the same only when the listener is attached to the target itself; in every other case they diverge, and this divergence is the basis for the whole of the next lesson.
When the listener runs depends on the model defined in the Event Loop lesson of the Asynchronous JavaScript and Runtime course: when an event is produced, the listener is not called immediately, it is queued as a task and run once the call stack is empty. A long-running listener therefore does not only delay itself, it delays every event in the queue.
The Propagation Path
When an event is produced, the browser first computes the propagation path: the chain of ancestors from the root node to the target. This path is fixed before the event starts propagating and does not change even if the tree changes during propagation.
Every node along the path is visited twice: once descending from root to target, once ascending from target to root. These two passes produce three phases.
The capture phase descends from root to the target’s parent. Listeners bound to ancestors and registered in capture mode are called here.
The target phase is the target itself.
The bubble phase ascends from the target’s parent to root. Listeners bound to ancestors and registered in bubble mode are called here.
// propagation.mjs — a small model of three-phase event propagation const element = (name, ...children) => { const d = { name, children, parent: null, listeners: [] }; for (const c of children) c.parent = d; return d; }; const listen = (node, type, label, capturing = false) => node.listeners.push({ type, label, capturing }); // Walks from target to root, then reverses: the path always runs root to target. function path(target) { const chain = []; for (let d = target; d; d = d.parent) chain.push(d); return chain.reverse(); } const PHASE_NAME = { 1: "capture", 2: "target", 3: "bubble" }; function dispatch(target, type) { const chain = path(target); const calls = []; const run = (node, phase, capturing) => { for (const d of node.listeners) { if (d.type !== type || d.capturing !== capturing) continue; calls.push({ phase, node: node.name, listener: d.label }); } }; for (const node of chain.slice(0, -1)) run(node, 1, true); // capture run(target, 2, true); // target run(target, 2, false); // target for (const node of chain.slice(0, -1).reverse()) run(node, 3, false); // bubble return calls; } const button = element("button.save"); const row = element("tr.measurement", button); const table = element("table#log", row); const main = element("main", table); const body = element("body", main); listen(body, "click", "body-capture", true); listen(main, "click", "main-capture", true); listen(table, "click", "table-bubble"); listen(row, "click", "row-capture", true); listen(row, "click", "row-bubble"); listen(button, "click", "button-1"); listen(button, "click", "button-2", true); console.log("path:", path(button).map((d) => d.name).join(" > ")); console.log(); for (const c of dispatch(button, "click")) { console.log(String(c.phase).padEnd(2), PHASE_NAME[c.phase].padEnd(9), c.node.padEnd(14), c.listener); }
path: body > main > table#log > tr.measurement > button.save 1 capture body body-capture 1 capture main main-capture 1 capture tr.measurement row-capture 2 target button.save button-2 2 target button.save button-1 3 bubble tr.measurement row-bubble 3 bubble table#log table-bubble
The output shows what happens when the save button in a row of the station page’s measurement table is clicked. Seven listeners are registered, and all seven are called; the calling order does not depend on registration order but on the node’s position on the path and the listener’s phase.
The table-bubble listener bound to the table#log node is third in registration order
but last in calling order. The reason is that the bubble phase walks the path in the
reverse direction: the ancestor closest to the target is called first, the farthest
ancestor last.
Order at the Target
The output’s fourth and fifth lines carry a detail: button-2 was called before
button-1 even though it was registered last. The only difference between them is that
button-2 was registered in capture mode.
The reason is that the path is walked twice. The target node is visited on both passes: listeners in capture mode are called on the descending pass, ones in bubble mode on the ascending pass. The phase name is reported as “target” in both, but the order comes from the pass.
This behavior can be surprising in cases where a listener is bound to the same node in both capture and bubble mode. The rule is simple: capture-mode listeners on the same node always run first, independent of registration order. Listeners of the same mode keep their registration order among themselves.
Stopping Propagation
A listener can cut short the event’s journey along the path. There are two separate operations, and their effects differ.
// stopping.mjs — two forms of stopping propagation, and a non-bubbling event const element = (name, ...children) => { const d = { name, children, parent: null, listeners: [] }; for (const c of children) c.parent = d; return d; }; const listen = (node, label, options = {}) => node.listeners.push({ label, capturing: options.capturing ?? false, action: options.action }); function path(target) { const chain = []; for (let d = target; d; d = d.parent) chain.push(d); return chain.reverse(); } function dispatch(target, { bubbles = true } = {}) { const chain = path(target); const event = { stopped: false, stoppedImmediately: false, called: [] }; const run = (node, capturing) => { for (const d of node.listeners) { if (d.capturing !== capturing) continue; if (event.stoppedImmediately) return; // also skips the rest at the same node event.called.push(node.name + "/" + d.label); d.action?.(event); } }; for (const d of chain.slice(0, -1)) { if (event.stopped) break; // does not move to the next node run(d, true); } if (!event.stopped) run(target, true); if (!event.stopped) run(target, false); if (bubbles) { for (const d of chain.slice(0, -1).reverse()) { if (event.stopped) break; run(d, false); } } return event.called; } function buildTree(rowAction) { const button = element("button"); const row = element("tr", button); const table = element("table", row); const body = element("body", table); listen(body, "body-capture", { capturing: true }); listen(row, "row-capture", { capturing: true, action: rowAction }); listen(button, "button-1"); listen(button, "button-2"); listen(row, "row-bubble"); listen(table, "table-bubble"); return button; } console.log("unimpeded :", dispatch(buildTree(undefined)).join(" → ")); console.log("stopPropagation :", dispatch(buildTree((e) => { e.stopped = true; })).join(" → ")); console.log("non-bubbling event:", dispatch(buildTree(undefined), { bubbles: false }).join(" → ")); // stopImmediatePropagation also blocks the next listener at the same node. const button = element("button"); const body2 = element("body", button); listen(body2, "body-bubble"); listen(button, "button-1", { action: (e) => { e.stoppedImmediately = true; e.stopped = true; } }); listen(button, "button-2"); console.log("stopImmediate... :", dispatch(button).join(" → "));
unimpeded : body/body-capture → tr/row-capture → button/button-1 → button/button-2 → tr/row-bubble → table/table-bubble stopPropagation : body/body-capture → tr/row-capture non-bubbling event: body/body-capture → tr/row-capture → button/button-1 → button/button-2 stopImmediate... : button/button-1
stopPropagation prevents the event from moving on to the next node. Other listeners
bound to the same node are still called. In the second line, the event stopped at the row
during the capture phase never reaches the target — the button’s own listeners never ran.
stopImmediatePropagation also skips the remaining listeners on the same node. In the
last line, button-2 was not called.
The choice between the two is a design decision. Code calling stopPropagation cancels
the event without knowing what is above it in the tree. A listener bound elsewhere on the
page to the entire document — one that closes an open menu, or saves a measurement — goes
silently inactive. For this reason, stopping propagation creates a dependency where two
pieces of code affect each other without knowing it, and it is used as a last resort.
Bubbling and Non-Bubbling Events
Not every event bubbles. The output’s third line shows this: in a non-bubbling event, the capture and target phases run as usual, only the bubble phase is skipped.
This distinction is often misunderstood. A non-bubbling event does not mean “only reaches the target.” Listeners bound to ancestors in capture mode do see it. Only ancestor listeners in bubble mode do not see it.
Examples of non-bubbling events follow a certain logic: declarations specific to a single element, with no meaning to ancestor nodes, do not bubble. Focus-gain and focus-loss events do not bubble; a separate, bubbling event type carrying the same information is defined. An image loading does not bubble. Events arising from user input — click, key press, input, submission — bubble.
Whether an event bubbles can be read from the event object’s bubbles field; this is
more reliable than guessing by looking at the document.
Listener Identity
A listener’s registration is identified by three pieces of information: the event type, the function reference, and the capture mode. Registering a second time with the same triple does not add a new listener, it is ignored. Registering the same function with a different mode, on the other hand, creates two separate listeners.
Removal is done with the same triple. A common mistake follows from this: a function defined inline at registration time, when written again at removal time, is a different function and cannot be matched. A listener’s function that needs to be removable must be bound to a name.
Two registration options solve this problem at the source. The once option removes the
listener on its own after the first call. The signal option accepts the cancellation
token defined in the Cancellation and Timeouts lesson of the Asynchronous JavaScript and
Runtime course; once the token is cancelled, every listener bound to it is removed in one
move. A component registering many listeners has its cleanup reduced to a single line
this way.
Summary
- When an event is produced, its propagation path is computed first: the chain of ancestors from root to target. The path is fixed before propagation starts.
- The path is walked twice and produces three phases: capture (root to target), target, bubble (target to root).
- On the same node, capture-mode listeners run before bubble-mode ones; within the same mode, registration order is preserved.
stopPropagationprevents the event from moving to the next node;stopImmediatePropagationalso blocks the remaining listeners on the same node.- Non-bubbling events skip only the bubble phase; capture and target phases stay unchanged.
- Listener identity is the triple of type, function reference, and capture mode; removal
requires the same triple, and the
onceandsignaloptions remove that requirement.
Next Step
This lesson showed that an event passes through its ancestors both before and after reaching the target. This journey is not a burden, it is an opportunity: a single listener bound to the target’s ancestor can see the events of every element beneath that ancestor. Binding a single listener to the measurement table, instead of a separate listener to every row, removes both the count of registrations and the problem of later-added rows staying without a listener. The next lesson establishes this technique, together with the rule for telling the target apart from the ancestor node.
To keep your progress and take notes, Log in
My notes
Log in to take notes.