JavaScript Event Listeners: Delegation vs. Closures

How to add event listeners to a set of repeating DOM elements

Matt Werner
ITNEXT

--

A common task I have come across in my young journey through JavaScript-land is adding event listeners to repeated DOM elements. For example, if we have a list with a bunch of <li>s on the DOM, we might want something to happen to one of the <li>s when we click on it. Maybe we want to change that <li>’s color, or animate it, or even remove it from the DOM. We will need a way to listen for an event on each <li>, then be able to perform an appropriate action based on which <li> was clicked. In my experience, there are two good ways to do this: using a method called delegation, and taking advantage of closures. In this post I will give a brief overview of each method, explain the differences, and finally dig into some code.

What is Delegation?

When we use delegation, we delegate the task of event listening to a single parent element that contains all of the other elements for which we want to handle events. If we have a <ul> that contains many <li>s, and we want to listen for an event on each of the <li>s, we can put one event listener on the entire <ul>, thus delegating the task of event listening from each <li> to the <ul> as a whole. Then, when a user clicks on the <ul>, we first check if they clicked on an <li> (as opposed to some other element/space in the <ul>) before performing any tasks. One really important thing here is that we find the smallest stable parent of all of our <li>s. Stable means on the source HTML. If our <ul> is on the HTML, we can feel more confident that it — as well as the event listener — will always be there, while the <li>s are dynamic and may come and go.
(For a more detailed explanation of event delegation, check out THIS ARTICLE.)

What is Closure?

Closures are a fundamentally important part of JavaScript and many other programming languages. Without going into too much detail, closures control which variables are available to a specific function. Remember that with event listeners, we pass a callback function as an argument. Well, depending on where we define the event listener, its callback function will have access to different variables. If we have a block of code that takes backend data and makes a new <li> for each piece of data, we can also attach an event listener to each <li> in the same block of code. If we do, each event listener’s callback function will have access to the specific data we used to make the <li>. It might not be clear now, but you will see through the coding example later why this is useful.
(Closure is a very important concept outside of event listeners and is prevalent in many programming languages. For more information about closures in JavaScript, read THIS ARTICLE.)

What’s the Difference?

There are a few key differences between delegating and using closures — some obvious, some less so:

  1. Delegation creates just one event listener, while leveraging closures results in one event listener for every element (each <li>, for example).
  2. Because delegation only uses one event listener, it uses little memory. When using closures, each new event listener (and any callback functions defined within the event listener) has its own piece of memory, so much more memory space is used.
  3. With delegation, the event listener is attached to a stable HTML element. When using closures, the event listeners are typically attached to dynamic DOM elements, making them much less stable.
  4. With delegation, we need to set our code up in a way that allows us to track information that the event listener’s callback function would otherwise not have access to. Event listeners using closures are more targeted, and we don’t have to worry about our code tracking information because if we do it right, the callback of the event listener will already have the information it needs.

The memory issue is something that could become very relevant depending on the scale of the application, but for most simple applications is not a huge concern. The last point is the reason I typically lean toward using closures whenever I can. The coding example below will illustrate this well.

Show Me the Code

Main image for story

Let’s use both of these methods to solve the same task. This task will be a slightly more complicated version of the <ul> / <li> examples above, but it will better explain the differences between the two methods. It will also show a more realistic functionality that we might want to build out in a frontend application.

The task is: We have a packing list application that allows users to keep track of the items they need to pack for a trip. On page load, the user should see each item displayed on the page in a new <li>. Each <li> should come with a delete <button>, so that when the user packs the item, they can delete the item by clicking the delete button.

For this task, let’s assume we have the following <ul> element in our HTML:

<ul id="items-list">
// <li>s will go here
</ul>

Alright, let’s begin!

Using Delegation

When we create all our new elements and add everything to the DOM, our DOM should have a basic structure this:

<ul id="items-list">
<li>Item 1<button>x</button></li>
<li>Item 2<button>x</button></li>
<li>Item 3<button>x</button></li>
...
</ul>

If all the user’s items are stored on our backend, then to produce the above DOM we might do something like this:

const itemsUl = document.querySelector("#items-list");fetch("http://localhost:3000/items")
.then(res => res.json())
.then(itemsArr => {
itemsArr.forEach(item => {
let itemLi = document.createElement("li");
itemLi.innerText = item.name;
itemsUl.append(itemLi);

let deleteButton = document.createElement("button");
deleteButton.innerText = "x";
itemLi.append(deleteButton);
});
});

Now, we want to listen for clicks on the delete <button>s within the <li>s. So we find the smallest stable parent of all of the delete <buttons> (the <ul> from the source HTML) and give it an event listener:

itemsUl.addEventListener("click", evt => {});

Then, we check to see if what was clicked was a <button>:

itemsUl.addEventListener("click", evt => {
if (evt.target.tagName === "BUTTON") {
};
});

Now, if a delete <button> was clicked, we want to remove the associated item from both the database and the DOM. To remove from the database, we need to make an appropriate fetch to the server. If we assume our backend is RESTful, our fetch will look something like this:

itemsUl.addEventListener("click", evt => {
if (evt.target.tagName === "BUTTON") {
fetch("http://localhost:3000/items/*item id*", {
method: "DELETE"
});
};
});

But wait, what is the item id that goes in the url of the fetch? How do we find it? Well, we could probably do it several ways, but I think the easiest is to add a data attribute to each <button> when we make it, then give that attribute a value equal to the associated item’s id. To do this, we add one line of code when we are making each <button>:

deleteButton.dataset.itemId = item.id;

So now the original block looks like this:

const itemsUl = document.querySelector("#items-list");fetch("http://localhost:3000/items")
.then(res => res.json())
.then(itemsArr => {
itemsArr.forEach(item => {
let itemLi = document.createElement("li");
itemLi.innerText = item.name;
itemsUl.append(itemLi);

let deleteButton = document.createElement("button");
deleteButton.innerText = "x";
deleteButton.dataset.itemId = item.id;
itemLi.append(deleteButton);
});
});

Now, when we click a delete <button>, we have access to the associated item’s id and can use it in our fetch to the server:

itemsUl.addEventListener("click", evt => {
if (evt.target.tagName === "BUTTON") {
let id = evt.target.dataset.itemId;
fetch(`http://localhost:3000/items/${id}`, {
method: "DELETE"
});
};
});

Perfect. Assuming our backend is working properly, this should remove the item from the database. Now to (pessimistically) remove the <li> from the DOM:

itemsUl.addEventListener("click", evt => {
if (evt.target.tagName === "BUTTON") {
let id = evt.target.dataset.itemId;
fetch(`http://localhost:3000/items/${id}`, {
method: "DELETE"
})
.then(() => {
let li = evt.target.parentNode;
li.remove();
});
};
});

We find the <button>’s parent node, which is the item <li>, then remove the whole <li> from the DOM. Putting everything together, our final code looks like this:

const itemsUl = document.querySelector("#items-list");fetch("http://localhost:3000/items")
.then(res => res.json())
.then(itemsArr => {
itemsArr.forEach(item => {
let itemLi = document.createElement("li");
itemLi.innerText = item.name;
itemsUl.append(itemLi);

let deleteButton = document.createElement("button");
deleteButton.innerText = "x";
deleteButton.dataset.itemId = item.id;
itemLi.append(deleteButton);
});
});
itemsUl.addEventListener("click", evt => {
if (evt.target.tagName === "BUTTON") {
let id = evt.target.dataset.itemId;
fetch(`http://localhost:3000/items/${id}`, {
method: "DELETE"
})
.then(() => {
let li = evt.target.parentNode;
li.remove();
});
};
});

Using Closures

To start, our code will look very similar to before:

const itemsUl = document.querySelector("#items-list");fetch("http://localhost:3000/items")
.then(res => res.json())
.then(itemsArr => {
itemsArr.forEach(item => {
let itemLi = document.createElement("li");
itemLi.innerText = item.name;
itemsUl.append(itemLi);

let deleteButton = document.createElement("button");
deleteButton.innerText = "x";
itemLi.append(deleteButton);
};
});

However, we do not have to add a data attribute on the <button> to store the specific item’s id. The reason why will become clear as we add on to our code (spoiler alert: it has to do with closures). Next, let’s put an event listener on each delete <button> right after we create it, within the forEach block:

const itemsUl = document.querySelector("#items-list");fetch("http://localhost:3000/items")
.then(res => res.json())
.then(itemsArr => {
itemsArr.forEach(item => {
let itemLi = document.createElement("li");
itemLi.innerText = item.name;
itemsUl.append(itemLi);

let deleteButton = document.createElement("button");
deleteButton.innerText = "x";
itemLi.append(deleteButton);
deleteButton.addEventListener("click", () => { });
});
});

Putting the event listener here gives us a couple of nice benefits. First, we don’t have to check if the thing that was clicked was actually a delete <button> like we did when we delegated because we are putting a listener on each delete button. Second, because the definition of the callback function of the event listener is inside the forEach block, it closes over the block, meaning it has access to everything inside the block. This in turn helps us with two tasks, the first of which is putting the right id in the url of our fetch to remove the item from the database:

const itemsUl = document.querySelector("#items-list");fetch("http://localhost:3000/items")
.then(res => res.json())
.then(itemsArr => {
itemsArr.forEach(item => {
let itemLi = document.createElement("li");
itemLi.innerText = item.name;
itemsUl.append(itemLi);

let deleteButton = document.createElement("button");
deleteButton.innerText = "x";
vLi.append(deleteButton);
deleteButton.addEventListener("click", () => {
fetch(`http://localhost:3000/items/${item.id}`, {
method: "DELETE"
});
});
});
});

Because we have access to the item variable (which is the data from the backend that we are iterating on in our forEach), we can easily pull its id and put it into the url for our fetch. This will remove the item from the database, then to remove it from the DOM:

const itemsUl = document.querySelector("#items-list");fetch("http://localhost:3000/items")
.then(res => res.json())
.then(itemsArr => {
itemsArr.forEach(item => {
let itemLi = document.createElement("li");
itemLi.innerText = item.name;
itemsUl.append(itemLi);

let deleteButton = document.createElement("button");
deleteButton.innerText = "x";
itemLi.append(deleteButton);
deleteButton.addEventListener("click", () => {
fetch(`http://localhost:3000/items/${item.id}`, {
method: "DELETE"
})
.then(() => itemLi.remove());
});
});
});

Notice that we don’t have to access the <li> by finding the parent node of the delete button like we did with delegation. Here, we have access to the <li> because the callback function in our .then() is closed over the callback function of our event listener, which is closed over the entire forEach statement where our itemLi variable lives.

The coolest part, in my opinion, is that even though each <li>, delete <button>, and event listener are created in the same block of code, each iteration of that code is a new lexical environment with new variables and new callback functions. Put more simply, each iteration is its own closure! Now that’s pretty neat, right?

Gif asking “How neat is that?”

Very neat.

Things to Consider

Both of these methods are perfectly valid, but depending on which method we use, we must keep some things in mind as we build out the rest of our functionality.

When we used delegation, we found the parent node of the delete <button> (which is the item <li>) in order to remove it from the DOM. But if for some reason the <button>’s parent node changes because of code that we (or our project partners or coworkers or anyone else) write in the future, we are going to have some issues. Along the same lines, if the <li>s and <button>s ever get moved out of the parent <ul>, our event listener will never know when the <button>s are clicked.

When we used closures to add individual event listeners to each <button>, we were tying these event listeners to specific DOM elements. But these DOM elements and listeners are not stable. If we, for example, change the inner HTML of our <ul> or <li>s through .innerHTML = or .innerHTML += , we are going to lose those event listeners. This is a danger of using .innerHTML in general, and it is very prevalent here.

Final Thoughts

Like just about anything in JavaScript, there are multiple ways to listen for events on repeating instances of DOM elements. The two methods discussed here, delegation and leveraging closures, are great options. For the vast majority of applications, either will be fine and the choice is up to you. More important than the choice is knowing why you are making that choice, and understanding how that choice impacts the rest of your application.

--

--