DEV Community

Cover image for Mastering Closures in JavaScript: A Comprehensive Guide
Imran Abdulmalik
Imran Abdulmalik

Posted on

Mastering Closures in JavaScript: A Comprehensive Guide

Closures are a fundamental part of JavaScript, providing an excellent tool for writing more maintainable and modular code. In this guide, we'll dive into the depths of closures, exploring their characteristics, uses, and the potential pitfalls and optimisations related to them.

   

Scoping in JavaScript

In JavaScript, scoping refers to the context in which values and expressions are "visible" or can be referenced. If a variable or other expression is not "in the current scope", then it is unavailable for use. There are two main types of scope in JavaScript: global scope and local scope, and with the introduction of ES6, block scope came into the picture as well.

 

Global Scope

When a variable is declared outside of a function, it is in the global scope and its value is accessible and modifiable throughout the program. Example:

var myGlobalVar = "This is global!";

function printSomething() {
  console.log(myGlobalVar);
}

printSomething(); // Output: This is global!
Enter fullscreen mode Exit fullscreen mode

 

Local (or Function) Scope

Variables declared within a function are in the local scope, and they cannot be accessed outside of that function. Example:

function printSomething() {
  var myLocalVar = "I'm local!";
  console.log(myLocalVar);
}

printSomething(); // Output: I'm local!

// Not allowed
console.log(myLocalVar); // Output: Uncaught ReferenceError: myLocalVar is not defined
Enter fullscreen mode Exit fullscreen mode

 

Block Scope

Introduced in ES6, let and const allow for block-level scoping, meaning the variable is only accessible within the block it’s defined. Example:

if (true) {
  let blockScopedVar = "I'm block scoped!";
  console.log(blockScopedVar); // Output: I'm block scoped!
}

console.log(blockScopedVar); // Uncaught ReferenceError: blockScopedVar is not defined
Enter fullscreen mode Exit fullscreen mode

As we've seen, when we define a function, variables defined within the function are only available from within the function. Any attempt to access those variables outside the function will result in a scope error. This is where closures (lexical scoping) comes in!

   

What are Closures in JavaScript?

JavaScript follows lexical scoping for functions, meaning functions are executed using the variable scope that was in effect when they were defined, not the variable scope that is in effect when they are invoked.

A closure is the combination of a function and the lexical environment within which that function was declared. In JavaScript, closures are created every time a function is created, at function creation time.

function outerFunction() {
  let outerVar = "I'm from outer function!";

  return function innerFunction() {
    console.log(outerVar);
  }
}
Enter fullscreen mode Exit fullscreen mode

In the example above, as a result of function local scoping, the innerFunction() is not available from the global scope. The innerFunction() can access all the local variables defined in outerFunction() because it is defined within the outerFunction(). The innerFunction() retains access to the outerVariable even outside its execution context.

The innerFunction() is still technically local to the outerFunction(). However, we can access the innerFunction() through the outerFunction() because it is returned from the function. Calling the outerFunction() gives us access to the innerFunction():

const closureFunction = outerFunction();
closureFunction(); // Output: I'm from outer function!
Enter fullscreen mode Exit fullscreen mode

   

Practical Use Cases of Closures

Closures have several practical use cases in JavaScript development. Closures are commonly used to create factory functions, encapsulate data, and manage state in callbacks and event handlers.

 

Data Privacy/Encapsulation

Closures can be used to create private variables or methods, which is a fundamental aspect of Object-Oriented Programming (OOP). By using a closure, you can create variables that can't be accessed directly from outside the function, providing a way to encapsulate data.

Example:

function createBankAccount(initialBalance) {
  let balance = initialBalance;
  return {
    getBalance: function() {
      return balance;
    },
    deposit: function(amount) {
      balance += amount;
      return balance;
    },
    withdraw: function(amount) {
      if (amount > balance) {
        console.log('Insufficient funds');
        return;
      }
      balance -= amount;
      return balance;
    },
  };
}

const account = createBankAccount(100);
console.log(account.getBalance()); // 100
account.deposit(50);
console.log(account.getBalance()); // 150
account.withdraw(30);
console.log(account.getBalance()); // 120
Enter fullscreen mode Exit fullscreen mode

In this example, a createBankAccount function is defined that takes an initial balance as a parameter. It initializes a balance variable and returns an object with three methods (getBalance, deposit, withdraw) that can access and modify the balance.

  • balance is not accessible directly from outside the function, ensuring data privacy.
  • getBalance allows you to view the current balance.
  • deposit adds a specified amount to the balance.
  • withdraw subtracts a specified amount from the balance, if sufficient funds are available.

When createBankAccount is called, it returns an object with methods that have access to the balance, even after the createBankAccount function execution context is gone. This is an example of a closure.

 

Maintaining State

Closures are a great way to maintain state between function calls. This characteristic is particularly useful when working with asynchronous code, event handlers, or any situation where you need to preserve a specific state over time.

Example:

function createCounter() {
  let count = 0;
  return function() {
    count += 1;
    console.log(count);
  };
}

const counter = createCounter();
counter(); // Output: 1
counter(); // Output: 2
counter(); // Output: 3
Enter fullscreen mode Exit fullscreen mode

In this example, the counter function maintains the count state between different calls.

  • createCounter initialises a count variable and returns an anonymous function.
  • Each time the returned function is called, it increments count and logs the value.
  • count retains its value between calls to the returned function, allowing it to act as a persistent counter.

 

Factory Functions

Closures can be used to create factory functions, which return objects with methods and properties that have access to the private data within the closure.

Example:

function personFactory(name, age) {
  return {
    getName: function() {
      return name;
    },
    getAge: function() {
      return age;
    },
    celebrateBirthday: function() {
      age += 1;
    },
  };
}

const john = personFactory('John', 30);
console.log(john.getName()); // Output: John
console.log(john.getAge()); // Output: 30
john.celebrateBirthday();
console.log(john.getAge()); // Output: 31
Enter fullscreen mode Exit fullscreen mode
  • personFactory takes name and age parameters and returns an object with three methods (getName, getAge, celebrateBirthday).

  • Each method has access to name and age, demonstrating closure behavior.

  • celebrateBirthday method can modify the age, illustrating how closures can update the enclosed variables.

 

Partial Application and Currying

Closures can be used to implement partial application and currying, which are techniques in functional programming.

Example:

function multiply(a, b) {
  return a * b;
}

function partialMultiply(a) {
  return function(b) {
    return multiply(a, b);
  };
}

const double = partialMultiply(2);
console.log(double(5)); // Output: 10
Enter fullscreen mode Exit fullscreen mode
  • partialMultiply takes a single parameter and returns a function.
  • The returned function takes another parameter and calls multiply, which takes two parameters.
  • This is a simple example of partial application, where a function that takes multiple parameters is broken into multiple functions that take one parameter each.

 

Event Handling

Closures are used in event handling to encapsulate the state and provide access to variables across different scopes.

Example:

function setupButtons() {
  for (var i = 0; i < 3; i++) {
    document.getElementById('button' + i).addEventListener('click', (function(i) {
      return function() {
        alert('Button ' + i + ' clicked');
      };
    })(i));
  }
}
Enter fullscreen mode Exit fullscreen mode
  • A loop is used to add event listeners to buttons.
  • An immediately-invoked function expression (IIFE) is used to create a closure that captures the current value of i for each iteration.
  • This ensures that each button click alerts the correct button number.

 

Timeouts and Intervals

Closures are used in setTimeout and setInterval to refer to the correct variable or state.

Example:

function delayedAlert(number) {
  setTimeout(function() {
    alert('Number: ' + number);
  }, number * 1000);
}

delayedAlert(5);
Enter fullscreen mode Exit fullscreen mode
  • setTimeout is used to delay the execution of a function.
  • The function passed to setTimeout has access to the number parameter, demonstrating a closure.
  • The alert will display the correct number even after a delay.

In each of these use cases, closures provide a way to manage and encapsulate state, allowing for more modular and maintainable code.

   

Exploring Advanced Closure Patterns

In the world of JavaScript, closures are a pivotal concept that allows functions to access variables from an outer function even after the outer function has executed. Beyond their basic use, there are advanced closure patterns that developers can use to create more efficient, readable, and maintainable code. This section delves into some of these advanced closure patterns and provides practical examples to illustrate their utility.

Advanced closure patterns in JavaScript are techniques that involve utilising closures to accomplish specific, often sophisticated, programming tasks. These patterns can enhance code modularity, encapsulation, and even performance.

 

1. The Module Pattern

The Module Pattern is a structural pattern that uses closures to create private and public encapsulated variables and methods within a single object.

Example:

const Calculator = (function() {
  let _data = 0; // private variable

  function add(number) { // private method
    _data += number;
    return this;
  }

  function fetchResult() { // public method
    return _data;
  }

  return {
    add,
    fetchResult
  };
}());

Calculator.add(5).add(3);
console.log(Calculator.fetchResult()); // Output: 8
Enter fullscreen mode Exit fullscreen mode

What's Happening?

  • A Calculator object is created using an Immediately Invoked Function Expression (IIFE).

  • Within the IIFE, _data is a private variable that is inaccessible outside of the function scope.

  • The add function takes a number as a parameter and adds it to _data. It returns this, allowing for method chaining (e.g., Calculator.add(5).add(3)).

  • The fetchResult function returns the current value of _data.

  • add and fetchResult are exposed to the external scope by being returned in an object.

Why is this Useful?

  • It provides a way to encapsulate private variables (_data) and methods (add), ensuring they cannot be accessed or modified externally.
  • The fetchResult method provides controlled access to _data.

 

2. The Factory Function Pattern

The Factory Function Pattern uses closures to encapsulate and return object instances with methods and properties, allowing for the creation of similar objects without explicitly class-based syntax.

Example:

function personFactory(name, age) {
  return {
    getName: function() {
      return name;
    },
    celebrateBirthday: function() {
      age += 1;
    },
    getAge: function() {
      return age;
    }
  };
}

const john = personFactory('John', 30);
john.celebrateBirthday();
console.log(john.getAge()); // Output: 31
Enter fullscreen mode Exit fullscreen mode

What's Happening?

  • personFactory is a function that takes name and age parameters.

  • It returns an object with three methods (getName, celebrateBirthday, getAge) that have access to name and age.

  • john is an instance of a person created by calling personFactory.

Why is this Useful?

  • It allows for the creation of multiple objects (person) without explicitly defining classes.
  • Encloses name and age within each created object, ensuring they cannot be accessed or modified directly from the outside.

 

3. The Revealing Module Pattern

The Revealing Module Pattern is a variation of the Module Pattern where only the necessary variables and methods are exposed, maintaining the encapsulation of all other components.

Example:

const ArrayAnalyzer = (function() {
  function average(array) {
    const sum = array.reduce((acc, num) => acc + num, 0);
    return sum / array.length;
  }

  function maximum(array) {
    return Math.max(...array);
  }

  return {
    average: average,
    maximum: maximum
  };
}());

console.log(ArrayAnalyzer.average([1, 2, 3, 4])); // Output: 2.5
Enter fullscreen mode Exit fullscreen mode

What's Happening?

  • ArrayAnalyzer is an object created using an IIFE.
  • It has two private functions, average and maximum, that are exposed as public methods in the returned object.

Why is this Useful?

  • It keeps the internal workings (calculations of average and maximum) encapsulated and exposes only the necessary interface.
  • Enhances code readability and maintainability.

 

4. Partial Application

Partial Application is a pattern where a function that takes multiple arguments is transformed into a series of functions each taking a single argument.

Example:

function multiply(a, b) {
  return a * b;
}

function partialMultiply(a) {
  return function(b) {
    return multiply(a, b);
  };
}

const triple = partialMultiply(3);
console.log(triple(5)); // Output: 15
Enter fullscreen mode Exit fullscreen mode

What's Happening?

  • partialMultiply is a higher-order function that takes a single argument a and returns a new function.
  • The returned function takes another argument b and calls multiply, passing in both a and b.

Why is this Useful?

  • It allows for the creation of new functions with preset arguments, enhancing code reusability and readability.

Advanced closure patterns like the Module Pattern, Factory Function Pattern, Revealing Module Pattern, and Partial Application leverage the power of closures to achieve various programming goals, from encapsulation to functional transformations. Understanding and implementing these patterns allow developers to write robust, efficient, and clean JavaScript code, enhancing the structure and manageability of their applications.

   

Closures and Memory

Closures provide a powerful and flexible way to work with functions and variables. However, it’s important to have a deep understanding of how closures interact with memory to ensure efficient code performance. In this section, we will discuss how they can impact memory usage, and provide strategies for optimising memory management when working with closures.

 

Closures and Memory Consumption

Closures retain their surrounding lexical context, which can sometimes lead to increased memory consumption or memory leaks if not managed correctly. Each time a closure is created, it holds a reference to its outer scope, preserving the variables and preventing them from being garbage collected.

Example:

function closureCreator() {
  let largeArray = new Array(10000).fill({});
  return function() {
    console.log(largeArray.length);
  }
}

const myClosure = closureCreator();
Enter fullscreen mode Exit fullscreen mode

In this code, largeArray is retained in memory as long as myClosure exists because it has access to the scope of closureCreator. If multiple closures like this are created, it can quickly lead to high memory usage.

 

Identifying and Preventing Memory Leaks

Strategies to manage memory when working with closures:

1. Avoid Unnecessary Variable Retention: Ensure that closures do not unnecessarily retain variables that are not used or needed beyond the life of the function.

2. Properly Release References: Explicitly nullify or delete references to objects or variables that are no longer needed.

function optimizedClosureCreator() {
  let largeArray = new Array(7000).fill({});
  return function() {
    console.log(largeArray.length);
    largeArray = null; // release the reference
  }
}
Enter fullscreen mode Exit fullscreen mode

3. Utilise Weak References: Consider using WeakMap or WeakSet to store references to objects, as these data structures do not prevent their objects from being garbage collected.

const weakMap = new WeakMap();
function weakMapClosureCreator(obj) {
  weakMap.set(obj, { key: 'value' });
  return function() {
    console.log(weakMap.get(obj));
  }
}
Enter fullscreen mode Exit fullscreen mode

4. Be Cautious With Event Listeners: Ensure to remove event listeners that use closures when they are no longer needed.

function setupButton() {
  const button = document.getElementById('myButton');
  const onClick = () => {
    // handle click
    button.removeEventListener('click', onClick);
  };
  button.addEventListener('click', onClick);
}
Enter fullscreen mode Exit fullscreen mode

Closures can also impact the execution speed of the code. Accessing variables outside of the immediate function scope is generally slower than accessing variables within the same scope. We can improve execution speed by:

  • Minimising the depth of scope chain access, avoiding deeply nested closures where possible.

  • Caching frequently accessed variables outside the closure to avoid repeated scope chain traversal.

Understanding the interaction between closures and memory is essential for writing efficient JavaScript code. While closures offer many benefits, it's crucial to manage their memory usage effectively to ensure optimal application performance. Employ the strategies and tools outlined above to write clean, efficient, and memory-optimised JavaScript code involving closures.

   

Closures in Modern JavaScript (ES6 and Beyond)

In modern JavaScript, the concept of closures remains a fundamental aspect of the language, enabling functional programming patterns, data encapsulation, and dynamic function generation. With the introduction of ECMAScript 6 (ES6) and subsequent versions, JavaScript has become more robust, providing new features and syntax that further leverage closures. This section explores closures in the context of modern JavaScript, discussing its applications and demonstrating modern syntax and patterns.

 

ES6 Enhancements

With ES6, the syntax and functionality around closures have seen improvements. Some notable features include:

 

Introduction of let and const

The introduction of let and const in ECMAScript 2015 (ES6) has had a significant impact on the way developers work with closures in JavaScript. Prior to ES6, var was the only keyword available for variable declaration, which has function scope. This led to issues and confusion, especially inside loops and blocks, affecting closures unintentionally. The let and const keywords address these issues by providing block scope to variables.

 

Block Scoping and Closures

Block-scoped variables (let and const) significantly influence closures, especially within loops and block statements. Consider this example with var:

for (var i = 0; i < 5; i++) {
  setTimeout(function() {
    console.log(i);
  }, i * 1000);
}
// Outputs: 5, 5, 5, 5, 5
Enter fullscreen mode Exit fullscreen mode

In this example, the setTimeout function creates a closure that captures the i variable, which is function-scoped. By the time the setTimeout callbacks execute, the loop has already finished executing, and i is 5.

Now consider using let in the same scenario:

for (let i = 0; i < 5; i++) {
  setTimeout(function() {
    console.log(i);
  }, i * 1000);
}
// Outputs: 0, 1, 2, 3, 4
Enter fullscreen mode Exit fullscreen mode

In this example, i is block-scoped due to the let keyword, meaning each iteration of the loop has its own i variable. The setTimeout callback captures the i variable from each iteration, outputting the expected results.

 

const and Immutable Closure States

const is another block-scoped variable declaration introduced in ES6, used for declaring variables whose values should not be reassigned.

function outer() {
  const message = 'Hello, World!';
  return function inner() {
    console.log(message);
  };
}

const closure = outer();
closure(); // Outputs: 'Hello, World!'
Enter fullscreen mode Exit fullscreen mode

In this example, the message variable is enclosed within the returned inner function. Because message is declared with const, it cannot be reassigned, ensuring the state captured by the closure remains immutable.

The introduction of let and const in ES6 significantly enhances working with closures in JavaScript. By providing block-scoping and ensuring immutability (const), these keywords offer more control and predictability, making it easier to create reliable and bug-free closures. Developers should prefer using let and const over var to benefit from these advantages while working with closures and other aspects of JavaScript.

 

Arrow Functions

Arrow functions provide a more concise syntax for function declaration, and they inherently create closures, carrying the scope of where they are defined.

const outer = () => {
  let outerVar = 'I am from outer function';
  const inner = () => console.log(outerVar);
  return inner;
};

const closureFunction = outer();
closureFunction(); // Output: 'I am from outer function'
Enter fullscreen mode Exit fullscreen mode

Arrow functions do not have their own this context, so this inside an arrow function always refers to the enclosing execution context, which is often a desirable behaviour for closures.

 

Default Parameters

ES6 allows default parameters in function definitions, which can be used effectively with closures.

const greet = (name = 'Guest') => () => `Hello, ${name}!`;

const greetUser = greet('User');
console.log(greetUser()); // Output: 'Hello, User!'
Enter fullscreen mode Exit fullscreen mode

 

Closures in Asynchronous Operations

Modern JavaScript, especially in Node.js and frontend frameworks, heavily employs asynchronous operations. Closures prove to be invaluable in handling asynchronous tasks, especially with callbacks and promises.

function fetchData(url) {
  return fetch(url)
    .then(response => response.json())
    .then(data => () => data);
}

fetchData('https://api.example.com/data')
  .then(getData => console.log(getData()));
Enter fullscreen mode Exit fullscreen mode

In the above example, fetchData fetches data from a URL and returns a closure that, when invoked, returns the fetched data.

Closures continue to be a fundamental and powerful feature in modern JavaScript, complementing the language’s asynchronous and functional nature. Understanding and efficiently using closures with modern syntax and features allows for cleaner, more efficient, and more robust JavaScript code.

   

Conclusion

Understanding closures is crucial for writing efficient and effective JavaScript code. This guide serves as a comprehensive overview, from the basics and practical use cases to debugging and performance considerations in using closures. Embrace closures, understand their behavior, and utilize them to write cleaner, more modular, and maintainable JavaScript code.

Cover Image by CopyrightFreePictures from Pixabay

Top comments (4)

Collapse
 
jonrandy profile image
Jon Randy 🎖️ • Edited

Closures in JavaScript occur when a function is defined within another function...

Not really. A closure is formed whenever a function is created, at the time of that function's creation - regardless of whether that was inside another function.

Collapse
 
imranabdulmalik profile image
Imran Abdulmalik • Edited

Thanks for the correction. I will fix this :)

Collapse
 
fernandesmike profile image
MI KE

So the statement "closure only exists when you have nested functions" is invalid?. That is what I initially learned so far.

Collapse
 
jonrandy profile image
Jon Randy 🎖️

Correct. Nested functions have nothing to do with it