Having been using ES6 for some time now, I've been seemingly immune to the pernicious ES5 related for-loop+async scoping problem. Every Javascript developer has encountered this at some point; but as a reminder, what does the following loop print to the console?
for (var i = 0; i < 5; i++) {
setTimeout(function() {
console.log(i);
}, i * 1000);
}
// => ?
It does not print 0...4
. Instead it outputs the number 5
, five times. Let's briefly review why this is happening before moving into how it's solved in ES5 and ES6 in various ways.
Why? Scope.
Scoping and asynchronous callbacks are the main things causing you to pull your hair out on this type of problem. Specifically, the following:
- The variable
i
in thefor
loop initializer is global in scope - Our callbacks passed to
setTimeout()
reference this globali
- Because our callbacks are async, the loop completes before any are executed.
Due to the above, when the callbacks do execute, the for
loop has already incremented the global variable i
to hold the value 5; and we get it repeated in our output since each of the callbacks references the same global i
.
for (var i = 0; i < 5; i++) {
setTimeout(function() {
console.log(i);
}, i * 1000);
}
//=> 5 5 5 5 5
If you've come to Javascript from a language that has block scoping, this type of hoisting of variables declared in the for
loop initializer can definitely be confusing.
So, how do we fix this?
The Fix? Closures!
Prior to ES6, which we'll cover in a bit, the way you solved this was by wrapping the code referencing the global variable i
in a closure which would capture the value i
at that point in the loop for use by the callback.
For example:
for (var i = 0; i < 5; i++) {
(function(n) {
setTimeout(function(){
console.log(n);
}, n * 1000);
})(i);
}
//=> 0 1 2 3 4
Here, we create a closure with an immediately invoked function expression (IIFE), which creates a closure around our code, capturing the value of i
in the IIFE's argument to keep it from changing when the callback is executed.
We could also have solved the problem using Function#bind
to capture the value of i
in a new function with that value applied as the first argument.
for (var i = 0; i < 5; i++) {
setTimeout(function(n){
console.log(n);
}.bind(null, i)
, i * 1000);
}
//=> 0 1 2 3 4
Note that we can still use i
directly in the second parameter to setTimeout()
because that code is executed synchronously each iteration of the loop - meaning i
will hold the correct value when evaluated. It's the async callback which executes outside the scope of the loop that caused us problems.
An alternative...
Something else to keep in mind here, is that using Array#forEach
(or other Array iteration methods) automatically makes this type of operation safe; because the callback passed to forEach
creates a closure around the values in the list via the declared arguments. For instance:
[0,1,2,3,4].forEach(function(n) {
setTimeout(function(){ console.log(n); }, n * 1000);
});
//=> 0 1 2 3 4
You could implement this with the above code, without using an array literal, by using something like underscore's _.range
method to dynamically create the array. But, if you have a list of values in an array, iterate them directly using forEach
or using jQuery or underscore's each
method, rather than a for
loop, to avoid this issue.
ES6 and let
to the rescue
With the introduction of let
and block scoping in ES6, the previously mentioned problem disappears - so long as you declare your for
loop initializers using let
instead of var
.
for (let i = 0; i < 5; i++) {
setTimeout(()=>console.log(i), i*1000);
}
//=> 0 1 2 3 4
Using let
in our loop initializer makes i
block scoped to the block of code within the loop. This properly captures the value for our callbacks and keeps us from polluting the global scope with for loop initializers all over the place. Not to mention, it keeps the code more readable and concise - nested IIFE's inside for
loops don't really add to code clarity or understanding and can be verbose to type.
In summary
This topic has been touched on in a number of resources online previously; but I hope that this post has shed some light on the nature of the issue.
You can find out more about ES6's block scoping in detail on Axel Rauschmayer's execellent post on the subject Variables and Scoping in ECMAScript 6.
You can also read my previous post on Javascript Scope for a more thorough treatment in the general sense, which covers both ES5 and ES6 scoping.