JavaScript For Loop Click Event ← Issues & Solutions Explained
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.
- Why do I need to know Hoisting?
- Why i variable always get the last index in a loop?
- Variable Scope Issue
- Solution #1: Closure (IIFE)
- Solution #2: Closure Outer Function Returns Inner Function
- Solution #3: Use forEach instead of for
- Solution #4: Use let instead of var
- Bonus: Declare Callback function outside of the loop
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 the 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.
✅Recommended
The Complete JavaScript Course 2020: Build Real Projects!
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 due to 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.
✅Recommended
JavaScript Working with Images
i
Is A Global Variable
I am going to console log the 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 the i
variable declared with the var
keyword.
Let’s take a look at a few solutions to fix it.
Solution #1: Closure
We can use closure to change the scope of the i
variable making it possible for functions to have private variables.
Using closure, 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 closure using ()
opening and closing parenthesis inside the for
loop.
After that, declare an anonymous function that 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 the 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 the 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.
✅Recommended
JavaScript Basics for Beginners
Solution #2: Closure Outer Function Returns Inner Function
Alternatively, you can return a function that is inside the closure callback function.
button.addEventListener("click", function(index) {
return function(){
console.log(index)
}
}(i))
In the previous example, the entire 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 a 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!
✅Recommended
CORS Error & Solutions in a Nutshell
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 also hoisted like var but they’re not initialized with a default value.
So, using the 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.
✅Recommended
JavaScript: Understanding the Weird Parts
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 the 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!
✅Recommended
MVC JavaScript Tutorial Using ES6 – Part 01
Conclusion
In this article, you 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 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!