Event delegation in JavaScript

Event delegation in JavaScript

1 Nov 2020 events javascript performance

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.

<div id=buttons> <button>One</button> <button>Two</button> <button>Three</button> </div>

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:

  1. We have a button inside a p tag that we want to ignore. Right now our code is listening for all button clicks inside the div.
  2. Our buttons have spans inside them; because our JS accepts clicks directly from buttons only, not elements inside buttons, our code won't run (the callback will still fire, but we'll exit at the return).

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.

if (evt.target.matches('#buttons > button')) return;

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 spans. 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.

if (!evt.target.closest('#buttons > button')) return;

That says: the clicked element must either itself be a direct button child of our parent div, or, in the case of our spans, 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.

Try the demo here.

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!