Puneet Sharma - Frontend Developer & UI Engineer
Puneet Sharma
Frontend Dev & UI Engineer · 16+ yrs · pixel-perfect HTML, React & WordPress

JavaScript Event Delegation: 10 Practical Examples with closest() and Event Bubbling

JavaScript event delegation explained with 10 live, editable demos — dynamic lists, closest(), data-action routing, and keyboard activation.
JavaScript Event Delegation Explained with Real UI Examples

Every developer hits the same wall eventually: you attach a click listener to every button in a list, then the app adds a new button, and it just doesn't work. The fix people reach for first is re-running the same addEventListener loop after every update — which is fragile, easy to forget, and wastes memory attaching hundreds of near-identical listeners. Event delegation solves this with one idea: put the listener on a stable ancestor instead of on the elements that keep changing, and let the browser's own event bubbling tell you what was actually clicked.

The 10 demos below are all live and interactive. Several include a side-by-side comparison so you can see the exact failure delegation prevents, not just take it on faith. Every embed also has a Fork & Edit button that opens the exact snippet, already running, in My Code — FWD Tools' free in-browser editor. Change a line, break it on purpose, see what happens — that's a faster way to build real intuition than any explanation.

The one-sentence rule

// Instead of this — one listener per item, re-run after every DOM change:
document.querySelectorAll('.item').forEach(function (item) {
  item.addEventListener('click', handleClick);
});

// Do this — one listener on a parent that never changes:
document.querySelector('.list').addEventListener('click', function (e) {
  var item = e.target.closest('.item');   // find the item that was actually clicked
  if (item) handleClick.call(item, e);
});

Clicking a child element doesn't just fire a listener on that element — the event bubbles upward through every ancestor, in order, unless something stops it. A listener on .list sees every click that happens inside it, whether the target was there when the page loaded or was created a second ago. event.target tells you exactly which element was clicked; .closest() walks up from there to find the meaningful container you actually care about. That's the entire mechanism. Every demo below is one consequence of it.

1. One Listener Handles Every Item You Ever Add

Add new to-dos, then click any of them — the ones on the page at load time and the ones you just typed.

The rule:

list.addEventListener('click', function (e) {
  var delBtn = e.target.closest('.del');
  if (delBtn) { delBtn.closest('.item').remove(); return; }

  var item = e.target.closest('.item');
  if (item) item.classList.toggle('done');
});

This is the case that motivates the whole pattern. There's exactly one click listener in this demo, attached once to <ul class="list">, and it's never touched again — not when an item is deleted, not when a brand-new item is typed in. .closest('.del') checks whether the click landed on a delete button first, and if not, .closest('.item') checks whether it landed on the row itself. New items work immediately because they're new descendants of an element that already has the listener; nothing about the listener needs to know they exist.

Best for: any list, feed, or board where items get added, removed, or reordered after the initial render. Tip: this is also why frameworks like React don't need you to think about delegation manually — they typically attach one listener per event type at the root and dispatch synthetically, which is delegation as an internal implementation detail.

2. Per-Item Listeners vs One Delegated Listener

Add 200 rows to both grids and watch the counters. One grid's listener count stays at 1 forever.

The rule:

// Direct: a new closure and a new listener for every cell, forever.
cells.forEach(function (cell) {
  cell.addEventListener('click', handleClick);
});

// Delegated: one listener, attached once, regardless of cell count.
grid.addEventListener('click', function (e) {
  var cell = e.target.closest('.cell');
  if (cell) handleClick.call(cell, e);
});

Both grids behave identically to a user — click any cell in either one and something highlights. The difference is entirely in setup cost. The direct approach attaches a fresh listener (and a fresh closure capturing scope) to every single cell as it's created, so the count climbs linearly with the number of cells and every element added later needs the exact same wiring repeated. The delegated approach never grows past one listener, because the container itself is doing the listening; it just asks "was this click inside a .cell?" every time.

Best for: large lists, tables, and grids — anywhere the item count is large or unbounded. Tip: the memory difference matters most for elements you frequently create and destroy (virtualized lists, live search results) — direct listeners on discarded elements are exactly the kind of thing that causes slow memory creep if you forget to clean them up, and delegation sidesteps the cleanup question entirely.

3. event.target vs event.currentTarget

The listener lives on the outer toolbar. Click the icon, the label, or the button padding and see which element each property reports.

The rule:

toolbar.addEventListener('click', function (e) {
  e.target;         // the exact element the user clicked — changes every time
  e.currentTarget;  // the element the listener is attached to — always .toolbar
});

This distinction trips people up because both properties are just "the element," but they answer different questions. currentTarget is fixed for the lifetime of that listener call — it's always whatever you called addEventListener on, in this case the toolbar div. target is whatever the pointer was actually over, which could be three levels deeper: an icon span, a text label, or the button itself. Delegation works because you attach to currentTarget but make your decisions based on target.

Best for: building the mental model before writing delegated code — most delegation bugs come from confusing these two. Tip: inside an arrow function, this isn't rebound to the listener's element, so reach for e.currentTarget there; a regular function listener gets the same value on this for free.

4. Rescuing Clicks with closest()

Click directly on the trash glyph — not just the button padding — in both lists.

The rule:

// Fragile: breaks the moment the button gets an icon, a span, anything nested.
if (e.target.className === 'del') { ... }

// Robust: walks up from whatever was clicked until it finds a match.
var btn = e.target.closest('.del');
if (btn) { ... }

The naive version checks e.target directly against an exact match, which is a hidden assumption that the button will only ever contain a bare text node. The instant someone wraps the label in a <span> for icon alignment or i18n, e.target becomes that span, its class list doesn't match, and the handler silently does nothing — one of the most common real-world delegation bugs, and one that's invisible in a quick manual test if you happen to click the padding instead of the icon. .closest() removes the assumption entirely by walking upward until it finds an ancestor (or itself) matching the selector, so it doesn't matter which descendant absorbed the click.

Best for: the default choice for delegated click handling — reach for .closest() over an exact target match essentially every time. Tip: .closest() is supported in every current browser and returns null when nothing matches, which is why it composes so cleanly with a plain if check.

5. Delegated Table Row Actions with data-action

Edit, duplicate, and delete are three different behaviors, routed through one listener via a data-action attribute.

The rule:

tbody.addEventListener('click', function (e) {
  var btn = e.target.closest('button[data-action]');
  if (!btn) return;
  var row = btn.closest('tr');

  if (btn.dataset.action === 'delete')    row.remove();
  if (btn.dataset.action === 'duplicate') row.after(row.cloneNode(true));
  if (btn.dataset.action === 'edit')      row.classList.toggle('editing');
});

Delegation isn't limited to one kind of action per container — a single listener can route to many behaviors by reading a data attribute off whichever element was clicked. This scales well: adding a fourth action later means adding one more branch to the same function, not wiring up a new listener anywhere. It also means rows created by "Duplicate" work immediately, because they inherit the exact same buttons with the exact same data-action values, and the listener never had to be told they exist.

Best for: admin tables, task lists, and any UI where each row exposes several distinct actions. Tip: prefer data-action values over parsing button text or class names for routing — text changes for localization, but a data attribute stays a stable contract between markup and script.

6. Delegated Dropdown Menu with Dynamic Items

Add a custom option to the menu, then select it — the selection listener was written before that option existed.

The rule:

menu.addEventListener('click', function (e) {
  var opt = e.target.closest('.opt');
  if (opt) selectOption(opt);
});
// Adding a new 
  • later needs zero new listener code.
  • Menus are a good stress test for delegation because their contents are often genuinely unknown at write time — populated from search results, recently-used lists, or in this case, user-created options. Attaching per-item listeners here would mean re-binding every time the list changes, including for items the menu itself creates in response to a click. The delegated version treats "select an option" as a property of the menu container, not of any individual option, which matches how the UI actually behaves.

    Best for: menus, comboboxes, and autocomplete lists where the option set is fetched, filtered, or user-extended at runtime. Tip: the outside-click-to-close handler in this demo is attached to document, which is delegation in the other direction — one global listener answering "did this click happen outside the menu?" instead of tracking blur state on every option.

    7. Delegating input Events Across Dynamic Form Rows

    Add more comment fields freely — one input listener on the form tracks every field's character count.

    The rule:

    form.addEventListener('input', function (e) {
      var textarea = e.target.closest('textarea');
      if (!textarea) return;
      updateCounter(textarea);
    });

    Delegation isn't a click-only trick — most events that matter for forms bubble the same way, including input, change, and focusin/focusout (note: plain focus and blur do not bubble, which is exactly why the "in" variants exist). This demo generalizes the pattern from click to typing: whichever textarea the user is typing in, at the moment they're typing in it, is what e.target resolves to, and a field added five minutes into the session is handled identically to one that was there from the start.

    Best for: multi-step forms, repeatable field groups ("add another attendee"), and any form built from a template that gets cloned. Tip: when you do need focus/blur behavior delegated, listen for focusin/focusout instead — same idea, but those two are designed to bubble so delegation actually works.

    8. When Delegation Breaks: a Stray stopPropagation()

    Click "Info" on each card, then click empty space on each card. One card's inner button quietly breaks the outer listener.

    The rule:

    // Inside the card's own inner button handler:
    innerBtn.addEventListener('click', function (e) {
      e.stopPropagation();   // this click will now NEVER reach the outer listener
      showInfo();
    });

    This is the demo worth studying if delegation has ever "randomly" stopped working for you. stopPropagation() does exactly what it says — it prevents the event from bubbling past the element that called it, which means any delegated listener sitting further up the tree simply never runs for that click. It's easy to add a stopPropagation() call for one specific reason (stopping an inner click from also triggering an outer "select card" behavior) and forget that it silently also blocks anything else listening higher up, including code you write later and code from a library you didn't write at all.

    Best for: debugging "my delegated listener isn't firing" — check every element between the click target and your listener for a stopPropagation() call before assuming your selector logic is wrong. Tip: reach for stopPropagation() only when you specifically need to stop a click from also being seen by an ancestor; if you just don't want a default browser action (like a link navigating), preventDefault() is almost always the one you actually want, and it doesn't affect bubbling at all.

    9. Delegating Keyboard Activation, Not Just Clicks

    These star ratings are <span role="button">, not real buttons. Tab to a star and press Enter or Space, or just click.

    The rule:

    rows.addEventListener('click', function (e) {
      var star = e.target.closest('.star');
      if (star) rate(star);
    });
    
    rows.addEventListener('keydown', function (e) {
      if (e.key !== 'Enter' && e.key !== ' ') return;
      var star = e.target.closest('.star');
      if (star) { e.preventDefault(); rate(star); }
    });

    A real <button> gets Enter and Space activation for free from the browser. A custom interactive element built from a <span> with role="button" gets none of that — you're responsible for reimplementing it, and delegation applies just as well to that responsibility as it does to clicks. One keydown listener on the container checks the key, then uses the exact same .closest('.star') lookup the click listener uses, so both input methods route through the same "what was activated" logic and stay in sync as rows are added.

    Best for: any custom control (star ratings, custom checkboxes, chip selectors) built from non-native elements — delegated keyboard handling is what keeps it accessible without duplicating logic per instance. Tip: if you're building this for real, prefer a native <button> in the first place whenever you can style your way there — this pattern is for the cases where a genuinely custom element is unavoidable.

    10. One Header Listener Sorts Any Number of Columns

    Click a column header to sort by it, click again to reverse. Adding a column needs zero new listener code.

    The rule:

    thead.addEventListener('click', function (e) {
      var th = e.target.closest('th');
      if (!th) return;
      sortBy(th.dataset.key, th.dataset.type);   // read config off the element itself
    });

    This is delegation combined with data-driven configuration, and it's the pattern that scales best to real applications. Instead of writing a separate addEventListener call — and a separate handler — for each column, the single thead listener reads data-key and data-type off whatever header was clicked and treats sorting as one generic operation parameterized by that data. Add a fourth column to the markup with its own data-key and it sorts correctly with no script changes at all, because the listener was never coupled to a specific column in the first place.

    Best for: data tables, especially ones where columns are configurable, added by a CMS, or driven by an API response. Tip: this "read config from data attributes on the clicked element" shape generalizes well beyond sorting — filter chips, tab bars, and settings toggles all benefit from the same one-listener-plus-dataset approach.

    Common pitfalls

    • Matching on e.target instead of walking up with closest(). Demo #4 shows this directly — a button with any nested markup breaks an exact-match check, while .closest() doesn't care how deep the actual click landed.
    • Forgetting that stopPropagation() blocks everything above it, not just the one behavior you were trying to stop. Demo #8 isolates this — it's the single most common reason a "correctly written" delegated listener appears to do nothing.
    • Assuming every event bubbles. Most do (click, input, keydown), but focus and blur don't — use focusin/focusout for delegated focus handling, as in demo #7's note.
    • Hardcoding behavior per element instead of reading it from the element. Demos #5 and #10 both route through a single handler by reading data-* attributes off whatever was clicked, which is what lets new elements work without new code.
    • Reaching for delegation when a native element would have solved it for free. Demo #9's keyboard handling exists because <span role="button"> has no built-in activation — a real <button> wouldn't need it. Delegate because you have to, not by default.

    Frequently asked questions

    Does event delegation work with every event type?

    It works with any event that bubbles, which covers most of the ones you'll use for delegation: click, input, keydown/keyup, change, mousedown/mouseup, and submit. A few don't bubble by design — plain focus and blur are the ones people trip over most, and both have bubbling equivalents (focusin, focusout) built for exactly this use case, as shown in demo #7.

    Is delegation always faster than direct listeners?

    For setup cost and memory, yes — one listener is cheaper than many, and demo #2 makes that difference visible directly. For the cost of handling a single click, the difference is negligible either way; the win is almost entirely about not re-binding listeners as the DOM changes, not about making an individual click faster.

    What's the difference between closest() and matches()?

    element.matches(selector) answers "does this exact element match?" and returns a boolean. element.closest(selector) answers "does this element, or the nearest ancestor of it, match?" and returns the matching element (or null). Delegation almost always wants closest(), because the actual click target is often a descendant (an icon, a span) of the element you actually care about — that's the exact scenario in demo #4.

    Can stopPropagation() break code I don't control?

    Yes, and that's the trap in demo #8. If any element between your click target and your delegated listener calls stopPropagation() — including code from a third-party widget or component library — your listener simply never sees that click. There's no error, no warning; it just silently doesn't fire. When a delegated listener "randomly" stops working for specific elements, this is the first thing to check.

    Do frameworks like React need manual event delegation?

    Not from you — React (and most modern frameworks) implements this internally, typically attaching one native listener per event type near the root and dispatching synthetic events to your component handlers from there. Understanding the underlying mechanism in these demos still matters because it explains framework behavior you'll otherwise find surprising, like why stopPropagation() on a synthetic event behaves the way it does relative to native listeners mixed into the same tree.

    Every demo above ships its full HTML, CSS and JS in the HTML / CSS / JS tabs in its own top bar. The fastest way to internalize this is to click Fork & Edit on demo #4 and change the naive list's exact-match check to e.target.closest('.del'), the same line the fixed list already uses — on that exact same markup, it immediately starts catching the clicks on the wrapped trash glyph that it was missing before. That single experiment explains more than any diagram. Once you've got a version you like, save it to My Code and it's yours to keep, tweak further, or drop into a real project.

    Recommended next step: open the JavaScript Playground — FWD Tools' full in-browser JS sandbox — and rebuild demo #5's delegated table actions from scratch with a fourth data-action of your own. Typing it out yourself, not just reading it, is what makes the pattern stick. For more tutorials like this one, browse the JavaScript label on the blog.

    About the author

    Puneet Sharma
    Puneet Sharma is a freelance web developer and the creator of FWD Tools and WebDevPuneet. Follow him on X/Twitter

    Post a Comment