NeuroAgent

JavaScript Closures in Loops: Complete Guide

Complete guide to solving JavaScript closure issues in loops. Understand why closures capture variables by reference and implement proper solutions for event listeners, async code, and more.

Question

JavaScript Closure Inside Loops: Practical Examples and Solutions

Problem Description

When creating functions inside a loop in JavaScript, the closure captures the loop variable by reference rather than by value. This causes all functions to reference the same final value of the variable, rather than the value at the time each function was created.

Example 1: Basic Loop

javascript
var funcs = [];
// let's create 3 functions
for (var i = 0; i < 3; i++) {
  // and store them in funcs
  funcs[i] = function() {
    // each should log its value.
    console.log("My value:", i);
  };
}
for (var j = 0; j < 3; j++) {
  // and now let's run each one to see
  funcs[j]();
}

Current Output:

My value: 3
My value: 3
My value: 3

Expected Output:

My value: 0
My value: 1
My value: 2

Example 2: Event Listeners

javascript
var buttons = document.getElementsByTagName("button");
// let's create 3 functions
for (var i = 0; i < buttons.length; i++) {
  // as event listeners
  buttons[i].addEventListener("click", function() {
    // each should log its value.
    console.log("My value:", i);
  });
}

Example 3: Asynchronous Code with Promises

javascript
// Some async wait function
const wait = (ms) => new Promise((resolve, reject) => setTimeout(resolve, ms));

for (var i = 0; i < 3; i++) {
  // Log `i` as soon as each promise resolves.
  wait(i * 100).then(() => console.log(i));
}

Example 4: Different Loop Types

javascript
const arr = [1,2,3];
const fns = [];

for (var i in arr){
  fns.push(() => console.log("index:", i));
}

for (var v of arr){
  fns.push(() => console.log("value:", v));
}

for (const n of arr) {
  var obj = { number: n }; // or new MyLibObject({ ... })
  fns.push(() => console.log("n:", n, "|", "obj:", JSON.stringify(obj)));
}

for(var f of fns){
  f();
}

Question

What is the proper solution to the JavaScript closure problem inside loops, ensuring that each function captures its own unique value of the loop variable rather than sharing a reference to the final value?

NeuroAgent

The JavaScript closure problem inside loops occurs because var declarations are function-scoped rather than block-scoped, causing all closures to capture the same reference to the loop variable. The proper solutions involve using block-scoped variables (let, const), creating scopes for each iteration, or using techniques like immediately invoked function expressions (IIFEs) to capture the correct value at each iteration.

Contents


Understanding the Core Problem

The JavaScript closure loop problem stems from how variable scoping works with var declarations. When you declare a variable with var inside a loop, it’s actually hoisted to the function scope, creating a single shared variable across all loop iterations. Each closure created inside the loop captures this same variable reference, leading to the infamous “late binding” issue where all functions see the final value.

Key Insight: The closure captures variables by reference, not by value. This means when the variable changes, all closures see the updated value.

In the provided examples, the problem manifests differently:

  • Basic Loop: All functions receive the final value 3 because that’s what i equals when they’re executed
  • Event Listeners: All button clicks will show the same value (typically the number of buttons)
  • Async Code: Promises resolve with the final loop value since the variable changes before they execute
  • Different Loop Types: for...in and for...of behave differently but share similar scoping issues

Modern ES6+ Solutions

Using let Declaration

The most elegant solution introduced in ES6 is using let instead of var. let is block-scoped, creating a new binding for each iteration:

javascript
const funcs = [];
for (let i = 0; i < 3; i++) {
  funcs[i] = function() {
    console.log("My value:", i);
  };
}

Why this works: Each iteration creates a new lexical environment for i, and each closure captures its own unique value.

Using const for Read-Only Cases

When you don’t need to reassign the variable, const provides the same block-scoping benefits:

javascript
const buttons = document.getElementsByTagName("button");
for (const i = 0; i < buttons.length; i++) {
  buttons[i].addEventListener("click", function() {
    console.log("Button index:", i);
  });
}

Arrow Functions with Block Scoping

Arrow functions maintain the same scoping behavior but offer more concise syntax:

javascript
const arr = [1, 2, 3];
const fns = [];

for (const i of arr) {
  fns.push(() => console.log("Value:", i));
}

Solution 1: let Declaration (Recommended)

javascript
var funcs = [];
for (let i = 0; i < 3; i++) {
  funcs[i] = function() {
    console.log("My value:", i);
  };
}
// Output: 0, 1, 2 as expected

Advantages:

  • Clean and modern syntax
  • No need for additional function wrappers
  • Works naturally with all loop types

Traditional Solutions for Legacy Code

Immediately Invoked Function Expression (IIFE)

For environments that don’t support ES6, you can create a new scope for each iteration:

javascript
var funcs = [];
for (var i = 0; i < 3; i++) {
  funcs[i] = (function(index) {
    return function() {
      console.log("My value:", index);
    };
  })(i);
}

How it works: The IIFE captures the current value of i in its own parameter index, creating a closure for each iteration.

Using Array Methods

Modern JavaScript offers functional programming approaches:

javascript
const buttons = document.getElementsByTagName("button");
Array.from(buttons).forEach((button, index) => {
  button.addEventListener("click", () => {
    console.log("Button index:", index);
  });
});

Solution 2: IIFE Pattern

javascript
var buttons = document.getElementsByTagName("button");
for (var i = 0; i < buttons.length; i++) {
  (function(index) {
    buttons[index].addEventListener("click", function() {
      console.log("Button index:", index);
    });
  })(i);
}

Advantages:

  • Works in all JavaScript environments
  • Explicit and clear about the scope creation
  • Flexible for complex scenarios

Advanced Scenarios and Edge Cases

Async/Await with Loops

When dealing with asynchronous code, the same principles apply:

javascript
async function processItems() {
  const items = [1, 2, 3];
  
  // Using let - works correctly
  for (let i = 0; i < items.length; i++) {
    await processItem(items[i], i);
  }
  
  // Using var - problematic
  // for (var i = 0; i < items.length; i++) {
  //   await processItem(items[i], i); // All calls would use final i
  // }
}

Nested Loops

Nested loops require careful attention to variable scoping:

javascript
// Problematic nested loop
for (var i = 0; i < 3; i++) {
  for (var j = 0; j < 3; j++) {
    setTimeout(() => console.log(`i: ${i}, j: ${j}`), 100);
  }
}

// Solution with let
for (let i = 0; i < 3; i++) {
  for (let j = 0; j < 3; j++) {
    setTimeout(() => console.log(`i: ${i}, j: ${j}`), 100);
  }
}

Solution 3: Async/Await with Proper Scoping

javascript
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

async function asyncLoop() {
  for (let i = 0; i < 3; i++) {
    await wait(i * 100);
    console.log("Async value:", i);
  }
}

// Or with IIFE for legacy environments
for (var i = 0; i < 3; i++) {
  (function(index) {
    wait(index * 100).then(() => console.log("Async value:", index));
  })(i);
}

Key Takeaway: Always use let for loop variables in modern JavaScript, and IIFEs for legacy code.


Best Practices and Recommendations

When to Use Each Solution

Scenario Recommended Solution Why
Modern JavaScript projects let declaration Clean, readable, efficient
Legacy code support IIFE pattern Universal compatibility
Functional programming Array methods More declarative style
Complex nested logic Block-scoped variables Prevents scope pollution

Performance Considerations

  • let declarations: Minimal performance overhead in modern engines
  • IIFE pattern: Slightly more expensive due to function creation
  • Array methods: Often more efficient for DOM operations

Code Readability and Maintenance

javascript
// Good: Clear and modern
const buttons = document.querySelectorAll('.button');
buttons.forEach((button, index) => {
  button.addEventListener('click', () => handleClick(index));
});

// Good: Explicit IIFE for complex cases
var legacyButtons = document.getElementsByClassName('button');
for (var i = 0; i < legacyButtons.length; i++) {
  (function(index) {
    legacyButtons[index].addEventListener('click', function() {
      handleLegacyClick(index);
    });
  })(i);
}

Common Pitfalls to Avoid

Mixing var and let in the Same Scope

javascript
// Problem: Still shares references
for (let i = 0; i < 3; i++) {
  var funcs = [];
  funcs[i] = function() {
    console.log(i); // Works correctly with let
  };
}

Assuming for...of and for...in Behave Differently

javascript
// Both can have the same closure issue
const arr = [1, 2, 3];

// for...in with var
for (var i in arr) {
  setTimeout(() => console.log(arr[i]), 100); // i is string, but still shared
}

// for...of with var
for (var item of arr) {
  setTimeout(() => console.log(item), 100); // item is shared reference
}

Solution 4: Comprehensive Fix for All Examples

javascript
// Example 1: Basic Loop
var funcs = [];
for (let i = 0; i < 3; i++) {
  funcs[i] = function() {
    console.log("My value:", i);
  };
}

// Example 2: Event Listeners
var buttons = document.getElementsByTagName("button");
for (let i = 0; i < buttons.length; i++) {
  buttons[i].addEventListener("click", function() {
    console.log("My value:", i);
  });
}

// Example 3: Async Code
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
for (let i = 0; i < 3; i++) {
  wait(i * 100).then(() => console.log(i));
}

// Example 4: Different Loop Types
const arr = [1,2,3];
const fns = [];

for (let i in arr){
  fns.push(() => console.log("index:", i));
}

for (let v of arr){
  fns.push(() => console.log("value:", v));
}

for (let n of arr) {
  var obj = { number: n };
  fns.push(() => console.log("n:", n, "|", "obj:", JSON.stringify(obj)));
}

for(var f of fns){
  f();
}

Conclusion

JavaScript closures inside loops are a common challenge that every developer encounters. The key solutions are:

  1. Use let for loop variables in modern JavaScript - this is the cleanest and most reliable solution
  2. Employ IIFE patterns when working with legacy code that doesn’t support ES6
  3. Leverage array methods like forEach for more declarative code
  4. Be consistent in your approach to avoid mixing scoping models

Remember that the closure problem isn’t about JavaScript being “broken” but rather about understanding how variable scoping works. By using block-scoped variables, you create the proper lexical environments needed for each closure to capture its intended value.

As JavaScript continues to evolve, the let and const declarations have largely solved this issue, making code more predictable and easier to maintain. However, understanding both modern and traditional solutions prepares you to work across different codebases and environments.

Sources

  1. MDN Web Docs - Closures
  2. JavaScript.info - Variable scope
  3. ECMAScript 2015 Language Specification
  4. You Don’t Know JS - Scope & Closures
  5. Google JavaScript Style Guide - Variable Declarations