Adding Click Event Listeners In A Loop In JavaScript

Javascript

When you attach multiple click events to elements such as buttons inside a for loop, the click event will always give us the last index value regardless of what button is pressed.

This is one of the common problems developers face when they start learning JavaScript.

By the end of this article, you will know what causes this issue and some of the ways to fix it.

Code Snippet That Causes The Problem

As you can see, the HTML page has a div element with an id called buttonsContainer.

<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width">
  <title>JS Bin</title>
</head>
<body>
  <div id="buttonsContainer"></div>
</body>
</html>

This is where I added five buttons dynamically using for loop in the javascript code below.

const buttonsContainer = document.getElementById("buttonsContainer");

for (var i = 0; i < 5; i++) {
  const button = document.createElement("button");
  button.innerText = i;
  button.addEventListener("click", function() {
    console.log(i)
  })
  buttonsContainer.appendChild(button);
}

I also attached a click event to a button element and appended it to the buttonContainer element on each iteration.

If I run this code at this stage, I will get a value of 5 regardless of what button is pressed.

Before understanding what’s happening here… we need to know…what is hoisting.

Hoisting

By default, a variable declared with var keyword is function scoped but not block-scoped.

So, any variable declared inside a function, regardless of how deep it is, will be moved to the top and accessible anywhere inside that function.

On the other hand, if a variable is declared outside of a function, it will become a globally scoped variable and we can access it anywhere in the application as it belongs to the window object (browser only).

That behavior is called Hoisting.

Variable i Always Has the Last Index

Let’s see what happens to the code above now.

The i variable declared with var keyword will be automatically moved to the top of the page as it’s not declared inside a function so it becomes a global variable because of hoisting.

So the i variable is clearly not scoped to the for loop but it’s globally scoped and it’s bound to the same variable outside of the callback function on each iteration.

By the time, the for loop reaches the last iteration, the i variable will end up holding the last index value. That’s why the output will always be the last index, in my case, 5.

i is a Global Variable

I am going to console log i variable outside of the for loop

} // end of for loop
console.log(i);

You will get 5 in the browser console as soon as the code finishes executing without even clicking any of the buttons.

This proves that the variable i is globally scoped.

Now we know the culprit, which is i variable declared with the var keyword.

Let’s take a look at a few solutions to fix it.

Solution 01: Closure

The closure is for rescue and we can use closure to change the scope of the i variable making it possible for functions to have private variables.

Using closures, we can uniquely save the loop index separately on each callback function.

for (var i = 0; i < 5; i++) {
  var button = document.createElement("button");
  button.innerText = i;
  (function(index){
    button.addEventListener("click", function() {
      console.log(index)
    })
  })(i)
  buttonsContainer.appendChild(button);

}
console.log(i);

Let’s see that in action.

First, define a closure using () opening and closing parenthesis inside the for loop.

After that, declare an anonymous function which takes the index parameter.

Then, pass the global variable i into the closure with the final set of ()’s, which calls the closure once on each iteration.

This is also called Immediately Invoked Function Expression (IIFE) which is one way of declaring closures.

(function(){
})()

So, the above code captures the value of an i variable at each iteration and passes it into an argument to the function which creates a local scope.

Now each function gets its own version of an index variable that won’t change when functions are created within that loop.

This closure function preserves the value of i (the private variable) uniquely for each event handler so they each have access to their own value.

When you click any of the buttons after the for loop ends, an appropriate callback function will be executed with a correct index value.

I hope that makes sense.

Solution #2: Closure Outer Function Returns Inner Function

Alternatively, you can return a function which is inside the closure callback function.

    button.addEventListener("click", function(index) {
      return function(){
        console.log(index)
      }
    }(i))

In the previous example, the whole button click event listener code is wrapped with the closure.

In this example, just the button click callback function is wrapped with a closure.

The outer function will be executed on every iteration and the i variable (global) is passed as an argument in the caller of the outer function like this (i).

The inner function will be returned on each iteration and attached to the click event with the unique index value.

In closure, the inner functions can have access to variables that are declared outside of it even after the outer function is returned.

Solution #3: Use forEach Instead of for

This would be great if you have an array of items and you can simply run the forEach() method on it but it’s not recommended when an asynchronous operation is happening on every iteration.

The forEach loop, by default, provides a clean and natural way of getting distinct callback closure function on every iteration.

const num = [0, 1, 2, 3, 4];

num.forEach(i => {
  var button = document.createElement("button");
  button.innerText = i;
  
  button.addEventListener("click", function() {
    console.log(i)
  })
  buttonsContainer.appendChild(button);
})

It works without adding any extra wrapper function which is cleaner than the previous example!

Solution #4: Use let Instead of var

In ES6, we have let and const keywords that are block-scoped in contrast to var that is function scoped. In other words, let and const are not hoisted.

So, using let keyword binds a fresh callback function with an index value on each iteration rather than using the same reference over and over again.

To fix that, change var to let from the original code and it works.

for (let i = 0; i < 5; i++) {
  const button = document.createElement("button");
  button.innerText = i;
  button.addEventListener("click", function() {
    console.log(i)
  })
  buttonsContainer.appendChild(button);
}

This is the quickest way to fix the click event issue inside a loop.

But, the one issue with this approach is to be careful with browser backward compatibility as it’s part of the ES6 feature.

Declare Callback Function Outside of the Loop

sometimes, we want to declare a callback function separately with a name rather than using an inline anonymous function inside addEventListener constructor.

So declare a callback function called buttonClicked() function and invoke it inside the addEventListener constructor without any parenthesis.

By default, the event object is passed into the buttonClicked() function.

Then, I can easily get access to any information about the selected element using an event object.

for (let i = 0; i < 5; i++) {
  const button = document.createElement("button");
  button.innerText = i;
  button.id = 'button-' + i;
  button.setAttribute('index', i);
  button.addEventListener("click", buttonClicked)
  buttonsContainer.appendChild(button);
}

function buttonClicked(e) {
  console.log(e.target.id)
  console.log(e.target.getAttribute('index'));
}

What if I want to pass a value directly to the callback function as an argument?

for (let i = 0; i < 5; i++) {
  const button = document.createElement("button");
  button.innerText = i;
  button.id = 'button-' + i;
  button.setAttribute('index', i);
  button.addEventListener("click", buttonClicked(i))
  buttonsContainer.appendChild(button);
}

function buttonClicked(index) {  
  return function() {
    console.log(index)
  }
}

This is very similar to solution #2.

When we pass a value to the buttonClicked, it becomes a closure (IIFE) which is the outer function and runs on each iteration.

The inner function will be returned on each iteration and attached to the click event with the index value.

And… that will do the magic!

Conclusion

In this article, you have learned how to fix the issue that arises when you attach click events inside for loop in a few ways.

Scoping in JavaScript is a big topic and I am still learning about the Closure and other concepts in JavaScript.

If you have any suggestions, feedback or if anything is unclear in this article, please reach out to me by commenting below.

I am looking forward to hearing from you and Happy Coding!