Event delegation in JavaScript

Event delegation in JavaScript

1 Nov 2020 performance delegation events javascript

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 Element.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 harness return and keep our code shallow, without entering a control structure inside the 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. New HTML:

<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 Element.closest() method, which demends an element, or one of its ancestors, match a given seletor.

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

So that says: the clicked element must be a direct child button of the div, or an element inside such a button.

Event 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); });

Try the demo here.

Event delegation & dynamic elements 🔗

Another huge benefit of event delegation, which should be obvious by this point, is that it means events will work on dynamic 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:

How do I make my click event with elements inserted into the page after the JavaScript runs?

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.

Simulating jQuery's on() method 🔗

Things get a little more complex from here. I include this part just for anyone who's interested.

Here's a working demo of the following.

Many developers who learned JavaScript via jQuery are familiar with event delegation without even realising it. jQuery made it ridiculously simple to delegate events, by introducing an optional second parameter to on().

$('#foo').on('click', function() { }); //direct event $('#foo').on('click', '.bar', function() { }); //delegated event

In the second example, we're doing event delegation just like we did above - listening in on elements with the class "bar" who live somewhere in the #foo container.

Wouldn't it be cool if we could mock up that function for our own use, without having to include all of jQuery? After all, native JavaScript doesn't give us event delegation - we have to do it ourselves, as we saw earlier. Let's have a stab at it.

Firstly let's set up a global container to store our events data. This will be a map with event types (e.g. "click") as keys.

window._events = null; HTMLElement.prototype.on = function(type, selector_or_callback, callback) { _events[type] = _events[type] || []; _events[type].push({ el: this, cb: callback || selector_or_callback, delSel: !callback ? null : selector_or_callback }); };

So what have we got so far? Firstly we define a method, on(), on the HTMLElement object's propertype (from which all elements inherit). When called, we store the event definition in our global window._events container, namely the element we're binding to, the callback and, if any, the delegation selector.

Like jQuery's on(), the arguments are fluid; the first is always the event type, but arguments two and three depend on whether we're delegating or not. If not, argument two is the callback; if yes, argument two is the selector and three the callback. We cater for both scenarios.

Now to listen for the events. We need to listen at the top level, i.e. on the body, and for all event types. Then, we'll look in our events container to see if anything should fire.

It isn't possible in native JS to bind a single listener to multiple types of event, so we'll need to do this in a loop.

const evtTypes = ['click', 'input', 'keydown'/* ... */]; //<-- add more types evtTypes.forEach(type => document.body.addEventListener(type, evt => { if (!_events[type]) return; _events[type] && _events[type][i].forEach(def => { if ( (evt.target == def.el || [...def.el.querySelectorAll('*')].includes(evt.target)) && (!def.delSel || evt.target.matches(def.delSel)) ) def.cb.call(evt.target, evt); }); }));

The scary-looking logic is basically to decide whether the callback should run or not. It compares the element we registered the event on with the actual event target, and also takes into account any delegation selector that was specified.

Like I said, it gets a little complex here.

So there you go. It isn't perfect; for one thing, it doesn't allow for event namespacing like jQuery does (we'd need to use the Custom Events API for that) and we currently have no way to unbind events. But you get the idea. Here's a demo of it working.

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!