20 — Event System: Bubbling, Capturing, Delegation & Custom Events

Event Propagation: Capture → Target → Bubble

javascript
// ── Events propagate in 3 phases (DOM event model) ──
//
//   Capture (top → down): document → ... → parent → target
//   Target:               target (the element that was clicked)
//   Bubble (bottom → up): target → parent → ... → document
//
//   <html>
//     <body>
//       <div>
//         <button>  ← target (click here)
//
//   Phase 1 (Capture): document → html → body → div → button
//   Phase 2 (Target):  button (both capture and bubble fire on target)
//   Phase 3 (Bubble):  button → div → body → html → document

// ── addEventListener: third arg = useCapture (default: false = bubble phase) ──
document.querySelector("#parent").addEventListener("click", (e) => {
    console.log("parent bubble:", e.target.tagName);
}, false);  // bubble phase (default)

document.querySelector("#parent").addEventListener("click", (e) => {
    console.log("parent capture:", e.target.tagName);
}, true);  // capture phase (fires FIRST — during top-down)

// Click on #child inside #parent:
// Capture order: document → ... → parent (capture fires) → child
// Target order: child (both capture and bubble fire on the target)
// Bubble order: child → parent (bubble fires) → ... → document

// ── e.target vs e.currentTarget ──
document.querySelector("#parent").addEventListener("click", (e) => {
    console.log(e.target);        // the ACTUAL element clicked (e.g., #child or a span inside)
    console.log(e.currentTarget); // the element with the listener (#parent)
    // target: where the event originated (deepest element)
    // currentTarget: the element that has the event listener (this in non-arrow)
});

Event Delegation

javascript
// ── Event delegation: one listener on a parent handles events for all children ──
// Instead of N listeners on N items, use 1 listener on the parent + event.target.

// ❌ NAIVE — one listener per item (memory + re-binding when items change)
document.querySelectorAll(".item").forEach(item => {
    item.addEventListener("click", () => console.log(item.dataset.id));
});
// Adding a new item? Must add a listener to it too.

// ✅ CORRECT — one listener on parent (event delegation)
document.querySelector("#list").addEventListener("click", (e) => {
    const item = e.target.closest(".item");  // find the closest .item ancestor
    if (!item) return;  // click wasn't on an .item (or descendant)
    if (!e.currentTarget.contains(item)) return;  // safety: item is inside the parent
    console.log("clicked:", item.dataset.id);
});
// Adding a new item? No need to add a listener — the parent's delegation handles it automatically.

// ── Delegation with data attributes for actions ──
document.querySelector("#app").addEventListener("click", (e) => {
    const action = e.target.closest("[data-action]");
    if (!action) return;

    switch (action.dataset.action) {
        case "delete":
            const id = action.dataset.id;
            deleteItem(id);
            break;
        case "edit":
            editItem(action.dataset.id);
            break;
        case "save":
            saveForm(action.closest("form"));
            break;
    }
});

stopPropagation vs stopImmediatePropagation

javascript
// ── stopPropagation: stop the event from propagating (bubbling/capturing further) ──
// But other listeners ON THE SAME ELEMENT still fire.
document.querySelector("#button").addEventListener("click", (e) => {
    e.stopPropagation();  // parent listeners won't fire
    console.log("listener 1");  // fires
});
document.querySelector("#button").addEventListener("click", (e) => {
    console.log("listener 2");  // ALSO fires (same element, different listener)
});

// ── stopImmediatePropagation: stop propagation AND prevent other listeners on same element ──
document.querySelector("#button").addEventListener("click", (e) => {
    e.stopImmediatePropagation();  // no further listeners fire (same element + propagation)
    console.log("listener 1");  // fires
});
document.querySelector("#button").addEventListener("click", (e) => {
    console.log("listener 2");  // DOES NOT fire (stopImmediatePropagation blocked it)
});

// ── preventDefault: prevent the default browser behavior ──
document.querySelector("form").addEventListener("submit", (e) => {
    e.preventDefault();  // prevents form submission (page reload)
    // handle the form with JS instead
});
document.querySelector("a").addEventListener("click", (e) => {
    e.preventDefault();  // prevents navigation
    // handle with client-side routing instead
});
// ⚠️ preventDefault doesn't stop propagation — the event still bubbles.
// To stop both: e.preventDefault(); e.stopPropagation();

Custom Events

javascript
// ── CustomEvent: create and dispatch custom events ──
const target = document.querySelector("#app");

// Listen for a custom event
target.addEventListener("app:ready", (e) => {
    console.log("app is ready", e.detail);  // detail: custom data
});

// Create and dispatch a custom event
const event = new CustomEvent("app:ready", {
    detail: { version: "1.0", timestamp: Date.now() },  // custom data (accessible via e.detail)
    bubbles: true,       // event bubbles (default: false)
    cancelable: true,    // event can be preventDefault'd (default: false)
    composed: true,      // event crosses Shadow DOM boundaries (default: false)
});
target.dispatchEvent(event);

// ── Use case: component communication ──
class Store extends EventTarget {
    #state = {};
    set(key, value) {
        this.#state[key] = value;
        this.dispatchEvent(new CustomEvent("change", {
            detail: { key, value, state: { ...this.#state } },
        }));
    }
}
const store = new Store();
store.addEventListener("change", (e) => {
    console.log("state changed:", e.detail.key, e.detail.value);
});
store.set("user", { name: "Alice" });  // dispatches "change" event

// ── AbortController: cancel event listeners (modern cleanup) ──
const controller = new AbortController();
document.addEventListener("scroll", handler, { signal: controller.signal });
// Later: cancel the listener (and any other listeners with the same signal)
controller.abort();  // removes the scroll listener (no need for removeEventListener)

Passive Listeners for Scroll Performance

javascript
// ── Passive listeners: tell the browser the handler won't call preventDefault ──
// This allows the browser to scroll immediately (without waiting for the handler to finish).
// Critical for touch/wheel events — non-passive listeners can cause scroll jank.

// ❌ NON-PASSIVE (default): browser waits for the handler before scrolling (can cause jank)
document.addEventListener("touchmove", (e) => {
    // if this calls e.preventDefault(), scrolling is blocked
    // the browser must WAIT for this handler to finish before scrolling
    updateIndicator();
}, { passive: false });  // default — browser must wait

// ✅ PASSIVE: browser scrolls immediately (handler runs but can't block)
document.addEventListener("touchmove", (e) => {
    updateIndicator();  // runs, but e.preventDefault() does NOTHING (passive ignores it)
}, { passive: true });  // browser doesn't wait — scrolls immediately
// Use passive for scroll/touch/wheel handlers that don't need preventDefault.

// ── once: listener auto-removes after first invocation ──
document.addEventListener("DOMContentLoaded", init, { once: true });
// equivalent to: document.addEventListener("DOMContentLoaded", () => {
//     init();
//     document.removeEventListener("DOMContentLoaded", init);
// });

// ── Combining options ──
document.addEventListener("scroll", handleScroll, {
    passive: true,   // won't call preventDefault (fast scroll)
    signal: controller.signal,  // can be aborted (clean removal)
});

💡 Tips & Tricks

javascript
// ── Event delegation with data attributes ──
list.addEventListener("click", (e) => {
    const action = e.target.closest("[data-action]");
    if (action) handleAction(action.dataset.action, action.dataset.id);
});

// ── Debounced scroll/resize listeners (avoid excessive calls) ──
const debounce = (fn, ms) => {
    let timer;
    return (...args) => { clearTimeout(timer); timer = setTimeout(() => fn(...args), ms); };
};
window.addEventListener("scroll", debounce(() => {
    console.log("scroll position:", window.scrollY);
}, 100));

// ── AbortController for bulk listener cleanup ──
function setupListeners(element) {
    const controller = new AbortController();
    element.addEventListener("click", handler1, { signal: controller.signal });
    element.addEventListener("input", handler2, { signal: controller.signal });
    element.addEventListener("keydown", handler3, { signal: controller.signal });
    return () => controller.abort();  // one call removes ALL three listeners
}
const cleanup = setupListeners(form);
// Later: cleanup();  // removes all listeners at once (no individual removeEventListener calls)

// ── Check if default was prevented ──
element.addEventListener("click", (e) => {
    if (!e.defaultPrevented) {  // another handler hasn't called preventDefault
        doSomething();
    }
});

// ── Dispatch synthetic events (for testing or automation) ──
const clickEvent = new MouseEvent("click", { bubbles: true, cancelable: true });
element.dispatchEvent(clickEvent);  // triggers click listeners as if the user clicked

⚠️ Edge Cases & Gotchas

javascript
// ── Arrow function event listeners can't be removed (no named reference) ──
element.addEventListener("click", () => doSomething());
// Can't remove this — there's no reference to the anonymous function.
// Fix: use a named function (or AbortController):
function handler() { doSomething(); }
element.addEventListener("click", handler);
element.removeEventListener("click", handler);  // ✓ can remove

// ── removeEventListener must match the exact same function and options ──
element.addEventListener("click", handler, { passive: true });
element.removeEventListener("click", handler);  // ⚠️ might not remove (options differ)
element.removeEventListener("click", handler, { passive: true });  // ✓ exact match

// ── `this` in event listeners ──
element.addEventListener("click", function() {
    console.log(this);  // `this` is the element (for regular functions)
});
element.addEventListener("click", () => {
    // console.log(this);  // `this` is NOT the element (arrow — lexical `this`)
    // Use e.currentTarget instead: console.log(e.currentTarget)
});

// ── Events on disabled elements don't fire ──
// <button disabled> — click events don't fire on disabled elements
// Fix: wrap in a parent and listen on the parent (event delegation)

// ── passive listeners can't call preventDefault (it's a no-op) ──
element.addEventListener("touchmove", (e) => {
    e.preventDefault();  // ⚠️ no-op! (passive: true ignores preventDefault)
    // Console warning: "Unable to preventDefault inside passive event listener"
}, { passive: true });

// ── Custom events don't bubble by default ──
const event = new CustomEvent("myevent");  // bubbles: false (default)
// Must set { bubbles: true } to bubble up the DOM

🧠 Quick Quiz

In what order do the handlers fire when you click #inner?

xml
<div id="outer">
    <div id="inner"></div>
</div>