Event Bubbling
Every click travels the full path twice — capture down from the window to
the target, then bubble back up. The demos below animate that walk: click
the Click button in the diagram and watch the wave move through the nested
div › ul › li › button, one step per beat. The cap / bub checkboxes
register and unregister real listeners — what the log shows is exactly
what a real console.log would print, and nothing more.
Event Bubbling in Vanilla JS
The native event handling under VanillaJS, animated.
These are real DOM listeners — unchecking a box unregisters it for the next click, and every logged line is one the browser actually delivered. Un-listening nodes stay silent: the DOM never notifies a pass-through, exactly like real debugging. Clicking the diagram background targets that node itself — watch the AT_TARGET order change.
The handlers behind the demo:
function Example() {
useEffect(() => {
const btn = document.getElementById('btn-example-vanilla')
const li = document.getElementById('li-example-vanilla')
const ul = document.getElementById('ul-example-vanilla')
const div = document.getElementById('div-example-vanilla')
if (!btn || !li || !ul || !div) return
const handleBtnClick = (e) => { console.log('Button clicked'); }
const handleLiClick = (e) => { console.log('Li clicked'); }
const handleUlClick = (e) => { console.log('Ul clicked'); }
const handleDivClick = (e) => { console.log('Div clicked'); }
const handleBtnClickCap = (e) => { console.log('Button clicked (Capture)'); }
const handleLiClickCap = (e) => { console.log('Li clicked (Capture)'); }
const handleUlClickCap = (e) => { console.log('Ul clicked (Capture)'); }
const handleDivClickCap = (e) => { console.log('Div clicked (Capture)'); }
btn.addEventListener('click', handleBtnClick)
li.addEventListener('click', handleLiClick)
ul.addEventListener('click', handleUlClick)
div.addEventListener('click', handleDivClick)
btn.addEventListener('click', handleBtnClickCap, true)
li.addEventListener('click', handleLiClickCap, true)
ul.addEventListener('click', handleUlClickCap, true)
div.addEventListener('click', handleDivClickCap, true)
// hold the element references instead of re-querying by id:
// on unmount the elements are already detached from the document,
// so getElementById would return null here
return () => {
btn.removeEventListener('click', handleBtnClick)
li.removeEventListener('click', handleLiClick)
ul.removeEventListener('click', handleUlClick)
div.removeEventListener('click', handleDivClick)
btn.removeEventListener('click', handleBtnClick, true)
li.removeEventListener('click', handleLiClick, true)
ul.removeEventListener('click', handleUlClick, true)
div.removeEventListener('click', handleDivClick, true)
}
})
return (
<div id="div-example-vanilla">
<ul id="ul-example-vanilla">
<li id="li-example-vanilla">
<button id="btn-example-vanilla">Click</button>
</li>
</ul>
</div>
);
}
Gotcha — the target fires in registration order. Capture-vs-bubble order
only governs ancestors. At the target node itself, both kinds of listener
are "at target" listeners and fire in registration order. The snippet
registers the bubble listener on btn first, so Button clicked logs
before Button clicked (Capture) — the demo registers its listeners in
exactly that order, and the browser itself decides the outcome; nothing
about the ordering is hardcoded in the demo.
Event Bubbling in React
Same semantics in JSX — but under the hood React 17+ delegates: one native listener pair on the root container, not per element. The outer band in the diagram shows the native event arriving at the root; everything inside it is React dispatching synthetic events down and back up.
These are real DOM listeners — unchecking a box unregisters it for the next click, and every logged line is one the browser actually delivered. Un-listening nodes stay silent: the DOM never notifies a pass-through, exactly like real debugging. Clicking the diagram background targets that node itself — watch the AT_TARGET order change. In React mode only the root band carries real listeners (delegation); the inner lines are React-style synthetic dispatch.
function Example() {
const handleBtnClick = (e) => { console.log('Button clicked'); }
const handleLiClick = (e) => { console.log('Li clicked'); }
const handleUlClick = (e) => { console.log('Ul clicked'); }
const handleDivClick = (e) => { console.log('Div clicked'); }
const handleBtnClickCap = (e) => { console.log('Button clicked (Capture)'); }
const handleLiClickCap = (e) => { console.log('Li clicked (Capture)'); }
const handleUlClickCap = (e) => { console.log('Ul clicked (Capture)'); }
const handleDivClickCap = (e) => { console.log('Div clicked (Capture)'); }
return (
<div onClick={handleDivClick} onClickCapture={handleDivClickCap}>
<ul onClick={handleUlClick} onClickCapture={handleUlClickCap}>
<li onClick={handleLiClick} onClickCapture={handleLiClickCap}>
<button onClick={handleBtnClick} onClickCapture={handleBtnClickCap}>
Click
</button>
</li>
</ul>
</div>
);
}
React normalizes the target-phase gotcha: onClickCapture always runs
before onClick on the target — unlike the DOM. That's the one place the
two animations differ in log order.