Global targets
window and document events: a global keydown shortcut, resize, scroll on window. There’s no JSX element to hang these on, because the target isn’t in your tree.
The browser's native event system, capture and bubble, delegation, and AbortController cleanup, the raw substrate that React's onClick is built on.
Here is something that should not happen, but does. You put a click listener on a button, and you click the button. Your handler runs, which is what you expected. But a listener you also attached to the surrounding <form> runs too, and one on document runs after that. You clicked the button. You did not click the form, and you certainly did not click the entire document. So why did three handlers fire?
They fired because the click did not “happen on the button.” It traveled. Every event in the browser takes a trip through the tree: down from the top to the element you touched, then back up to the top again. Any listener sitting along that route gets a turn. The button, the form, and the document are all on the route, and all three had a listener, so all three ran.
That single fact is the whole lesson, and everything else follows from it: why those three handlers fired, the difference between “what was clicked” and “what’s listening,” and the most useful pattern in all of DOM event handling. That pattern is delegation, where one listener on a parent serves any number of children. Delegation matters far beyond raw DOM work, because it is exactly what React’s onClick does under the hood. Learning the trip is learning what your event handlers compile down to.
The previous lessons built toward this. The first gave you the DOM as a live tree of nodes; the second split each node’s data into its parsed and live halves. Now you watch that tree react to clicks, typing, scrolling, every interaction a user has with the page. By the end you’ll be able to write a delegated click handler with closest() and data-*, tell preventDefault and stopPropagation apart without guessing, and clean up listeners the 2026 way with a single AbortController.
One reassurance up front, the same as the last two lessons: you will not write addEventListener in most of a React codebase. You’ll write onClick and let React do the wiring. So why learn the raw substrate? Because onClick is built directly on top of it, and the few places you do reach for addEventListener are unreadable without it. Seeing the substrate is what makes the React abstraction make sense later, rather than leaving it a black box you trust on faith.
Let’s watch the trip happen, since a definition list won’t make it stick the way the motion will.
When you click an element, the browser does not simply hand the event to that element. It starts at the very top of the tree, at window, and walks down the chain of ancestors toward the element you actually touched. Having reached it, the browser turns around and walks back up the same chain to the top. One event, two passes over the same path. The trip has three named phases, in this order:
window, through document, through each ancestor element, stopping just above the target . The name reflects that the outer elements get the first chance to intercept the event before it reaches its destination.window, the way a bubble rises through water. Listeners on ancestors fire on the way up.So in the opening mystery, the click started at window, descended through document and the <form> down to the <button>, then bubbled back up through the <form> and document to window. Three handlers sat along that path, so three handlers fired. There is no magic to it, only the trip.
Here is that exact trip, one tick at a time. Scrub it forward and the event descends to the button, then back up. The lit node is where the event is right now, and the caption names the phase and what fires there.
Before the click. The event hasn’t started its trip, so no listener has run yet. The tree is just sitting there: window wraps document, which wraps the <form>, which wraps the <button> you’re about to click.
Capture begins at the top. The event heads down toward the button. A listener registered with { capture: true } on window would fire now, before anything closer to the target sees it.
Still descending. The event passes through document; its capture-phase listeners fire here.
The last ancestor above the target. The <form>’s capture listeners get the event before the button ever sees it, which is exactly what “capture” means.
Target. The event reaches the <button>, the element you actually clicked. Listeners attached directly to it fire here, in the order they were added.
The event turns around and rises. The <form>’s listener fires. This is the first of the “surprise” handlers from the intro.
Still bubbling up. document’s listener fires next.
Back at the top. The trip is over. Three ancestors fired on the way up, even though you only clicked the button.
One fact about that trip decides how you write listeners every day. By default, addEventListener('click', handler) registers your handler on the bubble phase, not capture. The handler you write with no options runs on the way up. That is why the form and document handlers in the intro fired at all, since they had bubble listeners, and why they fired after the button, since bubble runs bottom-up, from the target outward.
You opt into the capture phase with a third argument:
button.addEventListener('click', handler); // bubble — the defaultwindow.addEventListener('click', handler, { capture: true }); // capture — the downward legKeep the proportions in mind, because they govern where you spend attention. Production code lives almost entirely on the bubble phase. Capture is a niche reach you’ll go years without typing. It exists for two real but rare jobs: intercepting an event at an ancestor before a child gets to handle it, and catching certain events at a parent that don’t bubble on their own.
That second job points at a sharp edge worth knowing now, because you’ll hit it the first time you try to react to focus. The focus and blur events do not bubble: they fire only at the target, so a listener on an ancestor never sees them. The platform’s answer isn’t to reach for capture. It’s a second pair of events, focusin and focusout, that do exactly the same thing but do bubble. When you need to know that focus entered or left some region, you reach for the bubbling pair. Hold that thought, because it matters again the moment we get to delegation.
Before moving on, fix the order in your mind, since the order is the one thing that has to be automatic.
A user clicks a link that sits inside a form, which sits inside the document. Every level — document, form, and the link itself — has a listener for both the capture and bubble phases. Drag the handlers into the order they fire. Drag the items into the correct order, then press Check.
document — capture phase form — capture phase a (the link) — target phase form — bubble phase document — bubble phase Read that sequence top to bottom and you can see the V-shape of the trip: down the left side through capture, a turn at the target, up the right side through bubble. The event goes outer-to-inner on the way down and inner-to-outer on the way back.
target vs currentTarget: what was clicked vs what’s listeningThe trip you just learned forces a distinction that confuses nearly everyone the first time, so let’s make it sharp now rather than let it cause trouble later. Inside any handler, the event object carries two different references to elements, and they answer two different questions.
event.target is what the user actually hit: the deepest node at the point of interaction. It is fixed for the entire trip. No matter which ancestor’s listener is currently running, target is always the element the event originated on.event.currentTarget is what this handler is attached to. It changes as the event moves: at each step of the trip, currentTarget is whichever node is running its listener right now. It’s the node you called addEventListener on.When a listener sits directly on the element you click, the two are the same and the distinction seems pointless. It stops being pointless the instant the listener sits on an ancestor, which, as you’re about to see, is every delegation handler. Picture one listener on a <ul>. Click any <li> inside it and the handler runs with currentTarget always pointing at the <ul> (what the handler is on), while target points at the specific <li> you clicked (what you hit).
// <ul id="menu">// <li>Profile</li>// <li>Billing</li>// <li>Sign out</li>// </ul>
const menu = document.getElementById('menu');
menu.addEventListener('click', (event) => { console.log(event.currentTarget.tagName); // 'UL' — always the <ul> the handler is on console.log(event.target.tagName); // 'LI' — whichever item you actually clicked});One sentence resolves the confusion every time: target is what was clicked; currentTarget is what the handler is on.
There’s a catch hiding in target that the next section depends on, so let’s surface it now. target is the deepest node, and that can be deeper than you think. If your <li> contains a <span> with an icon, and the user clicks the icon, then target is the <span>, not the <li>. It can even be a text node in some cases. So you can’t trust target to be the element you care about. It is only the element where the click landed. Climbing from wherever the click landed up to the meaningful element is exactly what closest() is for, and it’s the heart of the next section.
Predict what this logs. Getting it wrong here is the cheapest possible place to learn the lesson.
A list item contains a <span>. There is one click listener, on the <ul>. The user clicks directly on the <span> text. Predict the two logged lines. Predict what this program prints, then press Check.
// <ul id="menu">// <li><span class="label">Sign out</span></li>// </ul>
const menu = document.getElementById('menu');
menu.addEventListener('click', (event) => { console.log(event.currentTarget.tagName); console.log(event.target.tagName);});currentTarget is always the element the handler is bound to — the <ul> — so the first line is UL no matter where inside the list you click. target is the deepest node the click hit, and the click landed on the <span>, not the <li> wrapping it — so the second line is SPAN. This is precisely why a delegation handler can’t just trust event.target: you clicked inside the item you care about, but target points at a descendant of it. You climb back up to the real target with event.target.closest('li').
This is the pattern the whole lesson has been walking toward, and the one you’ll recognize everywhere once you know it.
Start with the problem it solves, because the pattern only makes sense once you’ve felt the cost of the alternative. Say you have a toolbar with three buttons: Save, Duplicate, Delete. The naive approach is to put a listener on each button:
saveButton.addEventListener('click', handleSave);duplicateButton.addEventListener('click', handleDuplicate);deleteButton.addEventListener('click', handleDelete);For three buttons this is fine. Scale it up to where real apps live, though, and two problems appear. First, a list of two hundred rows, each with an action button, means two hundred listeners: two hundred function references the browser holds, two hundred setup calls, all doing essentially the same job. Second, and worse, is the question of rows that don’t exist yet. A new row the user adds, or a list you fetch from the server after the page loads, arrives with no listener attached. You’d have to remember to wire one up every single time the DOM changes, and if you miss one, a button silently does nothing.
Delegation solves both problems at once. Instead of one listener per button, you put a single listener on a stable ancestor that’s always there: the toolbar, the <ul>, a container <div>. Every click on any descendant bubbles up to that ancestor, so the one listener sees them all. It works for the two-hundredth row exactly as well as the first, and it works for rows that didn’t exist when you attached it, because the event bubbles up regardless of when the child was added. One listener handles every child, present and future.
The shape is small and always the same. Walk through it part by part.
// <div id="toolbar">// <button data-action="save"><span class="icon">💾</span> Save</button>// <button data-action="duplicate">Duplicate</button>// <button data-action="delete">Delete</button>// </div>
const toolbar = document.getElementById('toolbar');
toolbar.addEventListener('click', (event) => { const actionEl = event.target.closest('[data-action]'); if (!actionEl) return;
switch (actionEl.dataset.action) { case 'save': saveDocument(); break; case 'duplicate': duplicateDocument(); break; case 'delete': deleteDocument(); break; }});One listener, on the stable container: not one per button, but one for the whole toolbar. Every click inside it bubbles up to here, so this single handler sees them all, including buttons added later.
// <div id="toolbar">// <button data-action="save"><span class="icon">💾</span> Save</button>// <button data-action="duplicate">Duplicate</button>// <button data-action="delete">Delete</button>// </div>
const toolbar = document.getElementById('toolbar');
toolbar.addEventListener('click', (event) => { const actionEl = event.target.closest('[data-action]'); if (!actionEl) return;
switch (actionEl.dataset.action) { case 'save': saveDocument(); break; case 'duplicate': duplicateDocument(); break; case 'delete': deleteDocument(); break; }});event.target is wherever the click landed, which might be the <span> icon inside Save rather than the button itself. closest('[data-action]') climbs up from there to the nearest element carrying the discriminator. This is the climb the previous section set up.
// <div id="toolbar">// <button data-action="save"><span class="icon">💾</span> Save</button>// <button data-action="duplicate">Duplicate</button>// <button data-action="delete">Delete</button>// </div>
const toolbar = document.getElementById('toolbar');
toolbar.addEventListener('click', (event) => { const actionEl = event.target.closest('[data-action]'); if (!actionEl) return;
switch (actionEl.dataset.action) { case 'save': saveDocument(); break; case 'duplicate': duplicateDocument(); break; case 'delete': deleteDocument(); break; }});The guard you can’t skip. If the click landed on the toolbar’s padding or a gap with no [data-action] ancestor, closest returns null. Returning early here avoids reading dataset off null. Forgetting this line is the single most common delegation bug.
// <div id="toolbar">// <button data-action="save"><span class="icon">💾</span> Save</button>// <button data-action="duplicate">Duplicate</button>// <button data-action="delete">Delete</button>// </div>
const toolbar = document.getElementById('toolbar');
toolbar.addEventListener('click', (event) => { const actionEl = event.target.closest('[data-action]'); if (!actionEl) return;
switch (actionEl.dataset.action) { case 'save': saveDocument(); break; case 'duplicate': duplicateDocument(); break; case 'delete': deleteDocument(); break; }});Route on the data-action value, read through dataset.action (the reading side of the data-* bridge from the previous lesson). Each case does its work. Add a fourth button tomorrow with data-action="archive" and you change nothing here but add one case.
This closes two threads the earlier lessons left open on purpose. The first lesson promised you’d see closest() “doing real work when this chapter reaches event delegation,” and this is that work: climbing from the clicked node up to the element that actually carries meaning. The second lesson promised that you write data-* in JSX and read it as dataset.* in a delegation handler, and this is the reading side finally happening. The discriminator you tagged each button with is what the one listener routes on.
Two refinements separate a delegation handler that works in the demo from one that works in production:
closest(), never trust target directly. You saw why in the predict exercise: target is the deepest hit node, often a descendant of the thing you mean. closest() is what keeps the handler working when the button wraps an icon, a span, or other nested markup.click, and it covers the keyboard for free. A native <button> fires a click event when activated by keyboard, with Enter or Space, not just by mouse. So a delegated click handler already serves keyboard users with no extra work. This is a real reason to build on click rather than mousedown/mouseup, which fire only for the pointer and leave keyboard users with no way to trigger the action.And recall the focus edge case from earlier: if you ever want to delegate focus handling, with one listener on a form noticing when any field inside it gains focus, focus won’t bubble to your container, so it’ll never arrive. Use focusin/focusout, the bubbling pair, and delegation works the same way it does for clicks.
Here is what makes this so useful: this is what React’s event system does. React does not attach a listener to every <button onClick> in your tree. It attaches a small number of listeners high up at the root, and when an event bubbles up, it figures out which component it belongs to and calls your handler. That’s delegation, run by the framework at scale. So when you write onClick, you’re not escaping the pattern you just learned; you’re using a polished version of it. The React side gets its own chapter later. Meeting the pattern here is what will make that machinery feel familiar rather than new when you get there.
Now write one yourself. Complete the delegated handler so the tests pass.
A toolbar uses event delegation. Complete resolveAction: given the node a click landed on, climb to the nearest element carrying a data-action and return its action string — or null if the click landed outside any action.
const resolveAction = (hitEl) => { const actionEl = hitEl.closest('[data-action]'); if (!actionEl) return null; return actionEl.dataset.action;};closest('[data-action]') climbs from wherever the click landed — including the <span class="icon"> inside the Save button — up to the nearest element carrying the discriminator, so a click on the icon still resolves to 'save'. The if (!actionEl) return null guard handles the click that landed on the <span id="gap"> outside any action, where closest returns null. Then actionEl.dataset.action reads the routed value — the same closest + dataset pair the annotated handler above is built on.
preventDefault and stopPropagation do different jobsThese two methods are confused constantly, by people well past their first year. The confusion comes from assuming they’re variations on one idea, “stop the event,” when in fact they control two completely independent things. Get the two axes straight and the confusion clears up.
event.preventDefault() cancels the browser’s default action for that event. It does not stop the trip. The event keeps right on traveling to every other listener; you’ve only told the browser “don’t do your built-in thing.” Clicking a link still runs every bubble listener, and preventDefault only stops the navigation. This is the one you reach for constantly.
event.stopPropagation() halts the trip. No further ancestors on the current leg get the event. It does not cancel the default action: if you stopPropagation on a link click but don’t also preventDefault, the browser still navigates. (There’s a stronger sibling, stopImmediatePropagation(), which also blocks any other listeners on the same element, not just ancestors.)
So the two are independent switches. You can flip either, both, or neither.
The normal case — the link navigates and the event reaches ancestors.
The link does NOT navigate, but ancestor listeners still run.
Ancestors don't hear it, but the link still navigates.
The link doesn't navigate AND ancestors don't hear it.
Two independent axes. preventDefault works the horizontal one (the browser’s built-in reaction); stopPropagation works the vertical one (the trip through the tree). They never touch each other’s axis, so you can flip either, both, or neither.
The everyday case lives in the preventDefault-only cell: a form’s submit handler.
form.addEventListener('submit', (event) => { event.preventDefault(); submitWithFetch(new FormData(form));});Without that first line, submitting the form triggers the browser’s default action: a full-page navigation that reloads everything and throws away your JavaScript state. preventDefault suppresses exactly that, leaving the event free to keep traveling while your own code takes over the submission. It’s the single most common use of either method. Note what it does not touch: it won’t disable the browser’s own field validation or autofill, since those aren’t part of the default action you’re cancelling.
Here is the warning that explains why this section sits right after delegation:
A few checks to make sure the two axes stay separate in your head.
Each statement probes the line between preventDefault and stopPropagation. Mark each one. Mark each statement True or False.
Calling stopPropagation() in a form’s submit handler prevents the page from reloading.
preventDefault().Calling preventDefault() stops the event from reaching listeners on ancestor elements.
preventDefault only cancels the browser’s built-in reaction. The event keeps traveling, so ancestor listeners still fire. Halting the trip is stopPropagation’s job.A child calling stopPropagation() can stop a delegated handler on a parent from ever running.
stopPropagation cuts the trip short, so the event never arrives — and nothing reports the failure. This is exactly why stopPropagation is treated as a design smell.You can call both preventDefault() and stopPropagation() on the same event.
You’ve already met one option, { capture: true }. That third argument to addEventListener is a small options object, and it has four flags worth knowing. Two of them barely come up; the other two are 2026 reflexes. Here they are, ordered by how often you’ll actually reach for them.
capture: true registers on the capture phase instead of bubble. You met it above, and it stays niche.once: true makes the listener remove itself automatically after it fires the first time. This is handy for genuine one-shot work: a setup step that should run on first interaction, or “first scroll” telemetry. You never have to remember to tear it down.passive: true is a promise to the browser that this handler will not call preventDefault. That promise lets the browser start scrolling or zooming immediately without waiting to see whether your handler blocks it, which is a real, measurable fix for scroll jank on touch and wheel events. Modern browsers already default passive to true for wheel, mousewheel, touchstart, and touchmove, but only on the document-level nodes (window, document, and document.body), and Safari doesn’t apply even that. So the senior reflex is to pass { passive: true } explicitly on scroll, wheel, and touch listeners on any other element, both because it’s clearer and because the automatic default doesn’t reach there. You only opt out, with { passive: false }, in the rare case where you genuinely must preventDefault, such as a custom gesture surface that needs to block native scrolling.signal: AbortSignal is the 2026 cleanup reach, and the reason this section exists. You hand the listener a signal, and aborting that signal removes the listener. This is what replaces the old chore of matching every addEventListener with a removeEventListener. It’s important enough to get its own section next.Here’s the shape, so the syntax is concrete before we lean on it:
const onWheel = () => updateParallax();const onFirstInteraction = () => trackEngagement();
panel.addEventListener('wheel', onWheel, { passive: true });panel.addEventListener('pointerdown', onFirstInteraction, { once: true });You’ll notice that second listener uses pointerdown, not mousedown or touchstart. On a touchscreen, mouse and touch events both fire for a single tap, so listening for one input type means handling the same tap twice through the other. The pointer* family (pointerdown, pointermove, pointerup) unifies mouse, touch, and pen into one event, which is what you reach for whenever you need raw pointer input. The family goes deeper than this; for now, just know it’s the one to default to.
Listeners have to be cleaned up. A listener you attach and never remove keeps its handler, and everything that handler closes over, alive in memory, and it keeps firing after you’ve stopped caring. In a long-lived single-page app, uncleaned listeners are a classic memory leak and a source of stray behavior. So every listener you add in code that can be torn down needs a way to be removed.
The old way to do that has two failure modes that show up in real codebases, and they’re worth seeing before the fix, because they’re why the fix exists. To remove a listener with removeEventListener, you must pass the exact same function reference you added. Compare the two approaches side by side: the same three listeners, torn down the old way and then the 2026 way.
const onScroll = () => updateHeader();const onResize = () => recomputeLayout();const onKeydown = (event) => handleShortcut(event);
window.addEventListener('scroll', onScroll);window.addEventListener('resize', onResize);document.addEventListener('keydown', onKeydown);
const cleanup = () => { window.removeEventListener('scroll', onScroll); window.removeEventListener('resize', onResize); document.removeEventListener('keydown', onKeydown);};The old pattern. Every listener needs a named reference held until cleanup, and every add needs a hand-matched remove. Miss one pairing and that listener leaks. Worse, an inline arrow such as addEventListener('scroll', () => …) has no reference you can pass back, so it can never be removed at all.
const controller = new AbortController();const { signal } = controller;
window.addEventListener('scroll', () => updateHeader(), { signal });window.addEventListener('resize', () => recomputeLayout(), { signal });document.addEventListener('keydown', (event) => handleShortcut(event), { signal });
const cleanup = () => controller.abort();The 2026 reflex. One controller’s signal goes into every listener, across different event types and different targets. A single controller.abort() removes all three at once. There are no references to hold and nothing to hand-match, and inline arrows are now perfectly fine, since you never need their reference.
That makes the two failure modes of the old way concrete. First, an anonymous handler can’t be removed at all, because there’s no reference to pass, so inline arrows leak with no sign of it. Second, tracking N handlers across N targets by hand is tedious and easy to under-clean: forget one pairing and that listener lingers.
The fix is one pattern, and it’s the central takeaway of this whole lesson. There are four points to it:
const controller = new AbortController(); at the top of wherever you wire listeners up.addEventListener in that scope. Pass { signal: controller.signal } to each one, and the same single signal works across multiple event types and multiple targets.controller.abort() in the cleanup branch removes every listener that shares the signal, all at once.If that AbortController-and-signal shape looks familiar, it should: it’s the same cancellation primitive used for aborting fetch requests and timing out async work. The course reaches for one consistent cancellation tool across the whole stack, and this is that tool applied to event listeners.
One forward glance, then we move on. In React, the cleanup branch isn’t a cleanup() function you call by hand. It’s a function React calls for you when a component unmounts. But the shape inside it is identical: create a controller when you set up, register listeners with its signal, call abort() on the way out. You’re learning the exact pattern the React lesson will lean on, so when you get there, it will already be familiar rather than new.
Now wire it up yourself. The setup is written for you; make cleanup() actually stop the listeners.
Two listeners are attached to bus. Complete the setup so a single AbortController wires them up and cleanup() removes both at once. After cleanup() runs, dispatching the events should change nothing — and reading controller.signal.aborted should be true.
const controller = new AbortController();const { signal } = controller;
bus.addEventListener('ping', () => { count += 1; }, { signal });bus.addEventListener('pong', () => { count += 1; }, { signal });
const cleanup = () => controller.abort();One AbortController, its signal threaded into both addEventListener calls — same signal, two listeners. A single controller.abort() in cleanup() removes both at once: after it runs, dispatching ping or pong finds no listener, so count stops moving, and controller.signal.aborted flips to true. No references held, no removeEventListener to hand-match — one signal, many listeners, one shutdown switch.
Let’s close the loop the chapter keeps returning to. React abstracts the substrate, but it doesn’t replace it, and knowing where the abstraction ends tells you exactly when to drop down to addEventListener.
For anything inside your component tree, React owns the listeners and you write onClick, onChange, onSubmit, never addEventListener. Under the hood, React attaches a small set of native listeners at the root container where your app is mounted (the element you render into, not document), normalizes each event into a SyntheticEvent , and dispatches it to the right component by walking the tree. That is the delegation pattern you just learned, run by the framework at scale. Most of the native surface carries straight through the wrapper: preventDefault, stopPropagation, target, and currentTarget all work on a SyntheticEvent the way they do on a native one, so everything in this lesson transfers. You might run across one bit of old code, event.persist(). It does nothing in modern React; it was a workaround for an event-pooling optimization that no longer exists, so if you see it, it’s a leftover no-op.
So when do you still reach for addEventListener in a React app? There are three escape-hatch sites, and recognizing them is the actual skill, because they’re where the substrate genuinely shows through. Each one lives inside React’s cleanup machinery, wired with the AbortController pattern you just learned.
Global targets
window and document events: a global keydown shortcut, resize, scroll on window. There’s no JSX element to hang these on, because the target isn’t in your tree.
Third-party DOM libraries
A library hands you a raw DOM node and its own events: Stripe Elements, a map, a charting lib. You wire its listeners imperatively, outside React’s tree.
Non-JSX browser APIs
Event-emitting platform objects that aren’t elements: MediaQueryList’s change, BroadcastChannel’s message, a WebSocket’s message. They’re event-driven, but not through the component tree.
And here’s the through-line of the whole lesson: each of those three sites is wired with the exact pattern you just practiced. You create one AbortController, thread its signal into every addEventListener, and call abort() in the cleanup branch. Learning the substrate handed you React-readiness for free. You didn’t learn raw events as a detour around React; you learned the thing React is built on, and the thing you’ll still reach for at its edges.
One last exercise, a judgment call. For each scenario, decide whether it’s plain React (onClick and friends) or one of the three escape hatches.
Sort each scenario by how you'd wire it: React owns it, or it's an addEventListener escape hatch. Drag each item into the bucket it belongs to, then press Check.
Escape-key shortcut on windowThe canonical platform references behind this lesson, if you want to go to the source:
The platform's own walkthrough of listeners, the event object, bubbling and capture, and delegation.
The full options surface (capture, once, passive, signal), including the exact passive-default behavior and its scope.
The cleanup primitive: one controller, a signal you thread into listeners (and fetches), and a single abort() that severs them all.
A deeper, example-driven walk through the three phases, target vs currentTarget, and why stopPropagation is a trap.
The delegation pattern in depth: the closest() climb, data-* routing, and the limits worth knowing before you lean on it.