Suggest an editImprove this articleRefine the answer for “Event propagation in JavaScript and its phases”. Your changes go to moderation before they’re published.Approval requiredContentWhat you’re changing🇺🇸EN🇺🇦UAPreviewTitle (EN)Short answer (EN)**Event propagation** describes how a browser delivers a DOM event through the element tree in 3 phases: capturing (root to target), target, and bubbling (target to root). ```js el.addEventListener('click', handler); // bubbling (default) el.addEventListener('click', handler, true); // capturing el.addEventListener('click', e => e.stopPropagation()); // stop travel ``` **Key:** Default is bubbling. Pass `true` for capturing. Check `event.eventPhase` to see the active phase: 1 = capturing, 2 = target, 3 = bubbling.Shown above the full answer for quick recall.Answer (EN)Image**Event propagation** is the path a browser takes to deliver a DOM event through the element tree: down from the root (capturing), at the target element, then back up (bubbling). ## Theory ### TL;DR - Clicking a button triggers 3 phases: capturing (root to target), target, bubbling (target to root) - Default `addEventListener` runs in the bubbling phase - Pass `true` as the third argument to register a capturing listener - `stopPropagation()` halts travel in the current direction, but handlers in the other phase may already have run - Use bubbling for event delegation on parents; use capturing for early interception on containers ### Quick example ```js ['grandparent', 'parent', 'child'].forEach(id => { const el = document.getElementById(id); // Capturing: fires top-down el.addEventListener('click', () => console.log(id + ' capture'), true); // Bubbling: fires bottom-up (default) el.addEventListener('click', () => console.log(id + ' bubble')); }); // Click #child outputs: // grandparent capture -> parent capture -> child capture // child bubble -> parent bubble -> grandparent bubble ``` When you click `#child`, capture listeners fire from grandparent down. Then bubble listeners fire from child back up. The target (`#child`) runs both in the same pass. ### The three phases Every click, keydown, or focus event travels the same route. The browser first builds a propagation path from `window` down to the target element using `composedPath()`, then walks that array in three passes. Capturing runs top-down. The event visits every ancestor of the target before arriving, giving each one a chance to intercept it early. Most developers skip registering capture handlers entirely. The phase still runs regardless. Target phase fires on the actual clicked element. Both capturing and bubbling handlers attached directly to that element run here, in the order they were registered. Bubbling runs bottom-up. The event climbs back along the same path. This is the default, so it is where most handlers live. ### Key difference Capturing lets you intercept an event before it reaches its destination. A modal overlay can catch clicks in the capture phase and block them from hitting child buttons. Bubbling is what makes event delegation work: one listener on a parent catches clicks from every descendant because those clicks travel upward through the tree. ### When to use - Block clicks before they reach children: capturing on an ancestor (modals, permission guards) - Handle dynamic lists without per-item listeners: bubbling on the parent, filter with `e.target.closest()` - React only to the exact element that was clicked: check `e.target === e.currentTarget` - Stop travel at a specific node: call `e.stopPropagation()` inside the handler ### How browsers build and walk the propagation path Chrome builds the full path via `composedPath()`, which returns an array from the target up to `window`. During dispatch the browser reverses that array for the capture pass, fires on the target, then walks forward for the bubble pass. `event.eventPhase` tells you which pass is active: `1` = capturing, `2` = at target, `3` = bubbling. Shadow DOM changes things. Events crossing a shadow boundary get retargeted: inside the shadow root `e.target` is the real source element, but outside the host it becomes the host element itself. Capture listeners outside the shadow cannot pierce the boundary. ### Common mistakes **Assuming `stopPropagation()` kills the event completely** ```js child.addEventListener('click', e => { e.stopPropagation(); // stops bubbling console.log('child handler'); }); // Capture listeners on ancestors already fired before this ran ``` `stopPropagation()` only stops travel in the current direction. If a parent had a capture listener, it already ran before the child received the event. **Expecting capturing behavior without `true`** ```js // Bubbling - runs after the child handler finishes parent.addEventListener('click', handler); // Capturing - runs before the child handler parent.addEventListener('click', handler, true); ``` Default is always bubbling. If you need the parent to act before the child, you need the `true` flag. **Trusting `e.target` without narrowing it in delegated handlers** ```js ul.addEventListener('click', e => { // e.target might be a <button> inside <li>, not the <li> itself console.log(e.target.textContent); }); // Fix: climb to the element you actually want ul.addEventListener('click', e => { const item = e.target.closest('li'); if (!item) return; console.log(item.textContent); }); ``` `e.target` is the deepest element that received the click. Use `closest()` to reach the container you care about. **Registering the same handler twice** ```js el.addEventListener('click', handler); el.addEventListener('click', handler); // both run independently // Fix: use {once: true} or remove before re-adding el.addEventListener('click', handler, { once: true }); ``` Two separate `addEventListener` calls create two separate handlers. `{once: true}` removes the handler automatically after the first call. ### Real-world usage - React 18: `onClick` delegates all events to the root container, not the individual element. That is why `e.target` stays correct even inside async callbacks - Vue 3: `@click.capture` opts into the capturing phase; `@click.stop` calls `stopPropagation()` automatically - jQuery / Bootstrap: `.on('click', selector, handler)` relies on bubbling delegation for dynamically added elements - Any dynamic list: one listener on the container, filter targets with `e.target.matches('li')` or `e.target.closest('li')` ### Follow-up questions **Q:** What does `event.eventPhase` return and when is it useful? **A:** It returns `1` during capturing, `2` when the event is on the target element, and `3` during bubbling. Reading it inside a handler lets you confirm which pass is currently active, useful when the same function handles both phases. **Q:** How does `stopPropagation()` differ from `stopImmediatePropagation()`? **A:** `stopPropagation()` prevents the event from moving to the next element in the propagation path. `stopImmediatePropagation()` does that and also cancels any remaining handlers on the current element registered in the same phase. **Q:** Does event propagation work across iframes? **A:** No. An iframe is a separate browsing context with its own `window` object. Events do not cross that boundary, which is by design for security reasons. **Q:** What is `composedPath()` and when do you need it? **A:** It returns the full propagation path including elements inside Shadow DOM slots. You need it in web components when you want the original click origin before retargeting happened at the shadow boundary. **Q:** In a Shadow DOM tree with slots, what happens to `e.target` during capture from the host? **A:** Capture starts at the document root. At the shadow boundary the browser retargets `e.target` to the host element for all external listeners. Inside the shadow root `e.target` still shows the actual clicked element. Calling `e.composedPath()` exposes the unmodified full path including all shadow slots. ## Examples ### Basic: three-level propagation trace ```html <div id="grandparent"> <div id="parent"> <button id="child">Click me</button> </div> </div> <script> ['grandparent', 'parent', 'child'].forEach(id => { const el = document.getElementById(id); el.addEventListener('click', () => console.log(id + ' capture'), true); el.addEventListener('click', () => console.log(id + ' bubble')); }); // Output when button is clicked: // grandparent capture // parent capture // child capture // child bubble // parent bubble // grandparent bubble </script> ``` The console trace shows the full travel path. Capture goes top-down, bubble goes bottom-up. The target element (`#child`) participates in both directions within the same dispatch cycle. ### Intermediate: event delegation on a dynamic list (React 18) ```jsx function TodoList({ todos }) { const handleClick = (e) => { const btn = e.target.closest('[data-delete]'); if (!btn) return; console.log('Delete todo id:', btn.dataset.id); // 'Delete todo id: 42' }; return ( <ul onClick={handleClick}> {todos.map(todo => ( <li key={todo.id}> {todo.text} <button data-delete data-id={todo.id}>Delete</button> </li> ))} </ul> ); } ``` One listener on `<ul>` handles deletes for every item. When todos are added or removed, no listener setup is needed. Bubbling carries each click up to the parent automatically. ### Advanced: `stopPropagation()` does not undo capturing ```js const parent = document.getElementById('parent'); const child = document.getElementById('child'); // Capture on parent runs first, before child gets anything parent.addEventListener('click', () => console.log('parent capture'), true); // Child stops bubbling after handling child.addEventListener('click', e => { e.stopPropagation(); console.log('child handler'); }); // This never runs - bubbling was stopped parent.addEventListener('click', () => console.log('parent bubble')); // Click on child outputs: // parent capture <-- capture already ran before stopPropagation had any effect // child handler // (parent bubble is skipped) ``` Stopping bubbling does not undo what capturing already did. The capture handler on `parent` ran before the child even received the event. I've seen this catch experienced developers who assumed `stopPropagation()` was a full kill switch for everything.For the reviewerNote to the moderator (optional)Visible only to the moderator. Helps review go faster.