Event delegation in JavaScript
1 Nov 2020
Event delegation in JavaScript has been around a long time, and remains an absolute must when handling events on anything but a small number of elements. Despite this, it's something beginner/intermediate developers frequently eschew. It's not uncommon to see questions on Stack Overflow with code like the following:
document.getElementById('button1').onclick = function() { ... };
document.getElementById('button2').onclick = function() { ... };
document.getElementById('button3').onclick = function() { ... };
//...and on and on
There's several problems here - but the one we're interested in is the fact each element has its own event rather than using event delegation
What is event delegation, and why use it? 🔗
Apart from horrible repetition, the main problem above is performance. Now, with modern browsers and CPUs being what they are, you're not likely to notice this with five elements, or even 50.
But once you get into the hundreds and thousands, having all those individually-bound events to track is hard work for the browser. It's suddenly got thousands of events to manage, and garbage collection in particular (which differs across browser implementations) can suffer.
Event delegation to the rescue! Event delegation means binding a single event callback to a common ancestor, rather than a separate callback to each element we're interested in. The key is to then establish which element triggered it, inside the callback.
Suppose the following HTML.
Let's bind our event callbacks via event delegation - using the common div#buttons
parent.
document.querySelector('#buttons').addEventListener('click', evt => {
if (!evt.target.matches('button')) return;
alert(evt.target.textContent); //e.g. "One"
});
What's happening there is our event callback fires on any and all clicks to our div
. The key, though, is the callback proceeds only if the click (denoted by evt.target
) came from a button
element within it. If it didn't, we quit (return). We infer this via the matches(selector)
method.
I prefer to test for the negative (i.e. didn't match) rather than the positive (did), as this allows us to keep our code more shallow by returning rather than entering an if
block, but either works.
More complex cases 🔗
We're not done yet, though. Let's add some complication to our HTML, then adapt our JavaScript to cope.
<div id=buttons>
<button>1 One</button>
<button>2 Two</button>
<button>3 Three</button>
<p><button>Ignore me</button></p>
</div>
There's two issues here that our JavaScript isn't, at present, ready to cope with:
- We have a
button
inside ap
tag that we want to ignore. Right now our code is listening for all button clicks inside thediv
. - Our buttons have
span
s inside them; because our JS accepts clicks directly frombutton
s only, not elements insidebutton
s, our code won't run (the callback will still fire, but we'll exit at thereturn
).
To solve the first problem we essentially need to demand that the click comes from a button
that is also a direct child of our div
, not merely a descendant.
To solve the second problem we need to relax our logic to allow the click to come either from the button
directly or an element within it, i.e. our span
s. We can do that by using the closest()
method.
closest()
traverses up the DOM hierarchy from a context element and looks for an element - either itself, or the nearest ancestor - that matches a selector.
That says: the clicked element must either itself be a direct button
child of our parent div
, or, in the case of our span
s, have a parent/ancestor that is.
Delegation = less code 🔗
It should be obvious by now that not only does event delegation bring performance benefits, it also generally involves less code. This can't be overstated, so let's consider another example.
Say we have a simple UI with a number, and three buttons to decrement, increment or reset it (to 0).
<p id=num>0</p>
<p id=buttons>
<button id=down>Decrement</button>
<button id=reset>Reset</button>
<button id=up>Increment</button>
</p>
Without delegation, we'd probably do something like this:
const num = document.querySelector('#num');
document.querySelector('#down').addEventListener('click', evt => {
num.textContent = parseInt(num.textContent) - 1;
});
document.querySelector('#reset').addEventListener('click', evt => num.textContent = 0);
document.querySelector('#up').addEventListener('click', evt => {
num.textContent = parseInt(num.textContent) + 1;
});
That's only three buttons, and it's already pretty horrible. How much simpler things can be with delegation...
document.querySelector('#buttons').addEventListener('click', evt => {
if (evt.target.matches('#reset')) return num.textContent = 0;
num.textContent = parseInt(num.textContent) + (evt.target.matches('#down') ? -1 : 1);
});
That first checks to see whether the #reset
button was clicked. If it was, we reset to 0 and exit (return) at that point. Beyond that point, we can safely assume the click was to one of the other buttons, and so we increment or decrement accordingly.
Delegation & dynamic elements 🔗
Another huge benefit of event delegation is works on dynamically-created elements - i.e. those injected into the DOM later - rather than just on those present when the page loaded.
Stack Overflow is full of variations on this question (example):
How do I make my click event work with dynamically-created elements?
With event delegation, this is guaranteed, because the evaluation as to which element triggered the event, and thus whether to proceed or not, is done inside the event callback, not at the time the event callback is bound. Contrast this with direct events:
document.querySelectorAll('.foo').forEach(el =>
el.addEventListener('click', () => alert('Click!'))
);
That will fire for any elements with the class "foo" that are present when the page loads only - not any that are injected into the DOM later. With event delegation, no such problem.
Delegation and unbinding 🔗
One of the traditional problems with direct events in JavaScript is unbinding.
Specifically, it's only really possible (without a lot of hackyness) to unbind an event that was originally bound with a function which is either named, or to which to retain a reference.
function foo(evt) null
let bar = function(evt) null
let el = document.querySelector('#myEl');
//named/referenced functions
el.addEventListener('click', foo); //<-- bind named function
el.addEventListener('click', bar); //<-- bind referenced anonymous function
el.removeEventListener('click', foo); //<-- no problem
el.removeEventListener('click', bar); //<-- also no problem
//unreferenced functions
el.addEventListener('click', evt => alert(1)); //<-- can't unbind!
The reason should be obvious enough. How can we unbind to a function which has no name and to which no lingering reference persists? The function, when we specified it, was fleeting in its nature.
This problem goes away with delegation, because we do much less direct binding (and, by extension, unbinding) of events. In fact, we can simulate unbinding with a simple on-off variable.
let allowEvent = true;
document.body.addEventListener('click', evt => {
if (!evt.target.matches('#foo')) return; //<-- delegate only to #foo element
if (!allowEvent) return; //<-- proceed if event not "unbound"
//do stuff
});
If we ever set allowEvent
to false, the callback will no longer do its thing. It'll still be called - the delegated event still fires, as we've not done any literal event unbinding - but the effect is the same in that their code (beyond the initial if conditionals) won't run.
Summary 🔗
Event delegation is a great idea because it's so much more performant in cases where you're dealing with an appreciable number of elements. It also invariably involves writing less code.
Event delegation involves binding a single event, to a common ancestor, rather than lots of separate events to the actual elements we're interested in.
Inside the callback, we then interrogate the event's target
to see what actually triggered the event, and decide wheher to proceed or not based on that. We can do this with methods like matches()
and closest()
.
I hope you've found this guide helpful!
Did I help you? Feel free to be amazing and buy me a coffee on Ko-fi!