Event delegation
Why it works at all, the reason that isn't performance, and the way it quietly breaks the keyboard.
$(list).on('click', '.remove', handler) is a line I must have typed several hundred times.
I understood what it did, roughly, and I understood it as a jQuery thing: a convenient signature where you pass a selector as the second argument and it magics up handlers for elements that don’t exist yet.
It isn’t a jQuery thing at all. It’s a property of the DOM that jQuery put a nice interface on, and it’s outlasted jQuery in my work by some margin, so it’s worth understanding on its own terms rather than as an API I once memorized.
Why it works
Events bubble. A click on a button lands on the button, then fires on its parent, then that element’s parent, all the way up to the document.
Which means a listener on a <ul> sees every click that happens on anything inside it, and can find out what the click actually landed on. One listener, on something stable, covering an arbitrary number of things inside it.
That’s the whole mechanism, and once you see it as bubbling rather than as a library feature, the gotchas all become predictable.
The reason isn’t performance
Everyone’s first explanation of this, including mine for years, is that attaching 400 listeners is expensive and one listener is cheap.
Which is true, and it’s not really why you’d do it. Modern browsers handle a few hundred listeners without complaint, and if performance were the only argument I’d mostly not bother.
The actual reason is dynamic content, and it’s a correctness argument rather than a speed one.
If you attach handlers to the elements that exist when the page loads, then anything added afterward has no handler. So every fetch that appends rows, every “load more,” every filter that re-renders a list, every template update, has to remember to re-attach. And forgetting to is one of the most common bugs in front-end work, because it doesn’t fail on page load, it fails on the second interaction, which is after the point where anybody tests.
It isn't about four hundred listeners being expensive. It's that a listener attached to an element that doesn't exist yet is a bug waiting for somebody to click twice.
Delegation removes the whole class of problem, because the only element that has to exist when you attach is the container, and the container was in the original HTML.
The version without a library
list.addEventListener('click', (e) => {
const button = e.target.closest('[data-action="remove"]');
if (!button || !list.contains(button)) return;
removeRow(button.closest('li'));
});
closest() walks up from whatever was clicked looking for a match, which is exactly the question you want to ask: “was this click inside a remove button, however deeply.”
The list.contains(button) check looks redundant and isn’t. closest() doesn’t stop at your container, it keeps walking to the document, so if there’s a matching element outside the list that happens to be an ancestor of your container, you’d match it. Rare, and it’s four words to be correct.
The four ways it goes wrong
target versus currentTarget. This is the classic. e.target is the deepest element the click landed on, which for a button with an icon inside it is the icon, not the button. e.currentTarget is the element your listener is attached to. If you read attributes off e.target directly, your handler works when people click the text and does nothing when they click the icon, and you will get a bug report that says “sometimes it doesn’t work.” That’s why the pattern above resolves through closest() first and then reads from the result.
Some events don’t bubble. focus, blur, mouseenter, mouseleave, and load and error on images. Delegating those simply doesn’t fire and there’s no error to tell you why. For focus there are bubbling equivalents, focusin and focusout, which is one of those facts you either know or lose an hour to. For hover, mouseover and mouseout bubble, at the cost of firing when moving between children.
stopPropagation() upstream kills it. If anything between the click and your container stops the event, your handler never runs. It’s almost never your own code: it’s a carousel plugin, a dropdown widget, or a third-party embed being defensive. Enormously annoying to debug, because everything looks correct, and the tell is that it works everywhere except inside one component.
Delegating too high. Attaching to document works and means every click anywhere on the page runs your handler and does a closest() walk. Once, fine. With 11 of these registered, on a page with a lot of interaction, it adds up. Attach to the nearest stable container instead. It’s also easier to reason about, and it means the handler stops running when that component is removed.
And use a real button
The one that matters most and that I nearly left out.
<!-- keyboard: nothing happens, ever -->
<div class="row"><div data-action="remove">Remove</div></div>
<!-- keyboard: Enter and Space fire a click event, for free, forever -->
<li><button type="button" data-action="remove">Remove</button></li>
Because delegation is usually written against a click event, it’s easy to forget that a <div> never receives one from a keyboard. A real <button> is focusable, announced as a button, and fires click on Enter and Space, so your delegated handler covers keyboard and pointer with no extra code at all.
The moment you use a div and a data attribute, you’ve taken on focus management, key handling, and a role, and you’ll do two of the three. Delegation is a JavaScript pattern that quietly depends on the markup being right, and the markup being right is free.
The list with the longest useful life
The thing I find interesting about this one, and the reason I wanted to write it up rather than just use it: it’s outlived the library that taught it to me by about six years, and it’ll outlive the next one too, because it isn’t a library feature.
There’s a short list of things like that. Bubbling. The cascade. Form submission. Links with hrefs. They’re properties of the platform rather than of whatever we’re all building on top of it this year, and they’ve each survived three or four cycles of everything above them being replaced.
I’ve stopped thinking of that list as the boring foundational stuff you learn first and move past. It’s the part with the longest useful life, and I’d hand a junior that list before anything else I know.