DEV Community

Audrey Kadjar
Audrey Kadjar

Posted on • Updated on

📦🔓Closures in JavaScript visualized

Have you ever experienced unexpected outcomes with JavaScript functions? Maybe it seemed like the return value was changing unexpectedly or magically 'remembering' previous values. Chances are, you were dealing with closures!

Though closures may seem intimidating at first, they reveal extraordinary capabilities once you grasp them.

To understand how closures work behind the scenes, we first need to explore the execution context and the Environment Record.

This article assumes you know the basics of scope in JavaScript.

The execution context

The execution context is a concept that describes the environment in which code is executed. It is defined by the ECMAScript® Language Specification(the official specification for JavaScript) as:

An execution context is a specification device that is used to track the runtime evaluation of code by an ECMAScript implementation. At any point in time, there is at most one execution context per agent that is actually executing code.

There are two main types of execution contexts: the global execution context and the function execution context.

The global execution context represents the global scope and is created when the script runs. The function execution context, on the other hand, is created each time a function is invoked, and each function call has its own execution context.

When the script runs, the JavaScript engine establishes the global execution context. Subsequently, every time a function is invoked, an execution context specific to that function is created. Consequently, there can only be one global execution context while there can be multiple function execution contexts. At any point in time, there is only one running execution context.

Explaining how execution contexts are managed is beyond the scope of this article, but you can learn more about it here (I'll write an article about this soon!).

execution contexts

In addition to keeping track of code execution, the execution context plays a pivotal part in resolving variables and function values within its scope. This resolution mechanism is facilitated via one of its vital components, the Environment Record.

The Environment Record

During code execution, the JavaScript engine first establishes the rules for variable and function resolution before executing the code. It is during this phase that an Environment Record is created for each execution context.

Similarly to the execution context, the Environment Record is an abstraction and cannot be directly accessed or manipulated within JavaScript code. It is defined by the ECMAScript® Language Specification as:

Environment Record is a specification type used to define the association of Identifiers to specific variables and functions, based upon the lexical nesting structure of ECMAScript code. Usually, an Environment Record is associated with some specific syntactic structure of ECMAScript code such as a FunctionDeclaration, a BlockStatement, or a Catch clause of a TryStatement. Each time such code is evaluated, a new Environment Record is created to record the identifier bindings that are created by that code.

Every Environment Record has an [[OuterEnv]] field, which is either null or a reference to an outer Environment Record. This is used to model the logical nesting of Environment Record values.

Note that in previous editions, the ECMAScript® Language Specification used the term "lexical environment" before it decided to rename it to "Environment Record" so you might encounter this term in other definitions and tutorials.

Think of the Environment Record as a container that holds variables and function values within a function declaration, block statement, or catch clause. It's important to note that block statements and catch clauses do not create new execution contexts on their own. The Environment Record associated with a block statement or catch clause is part of its outer execution context.

Each Environment Record can access its parent's Environment Record, forming a chain of access called the scope chain. The global scope, being the outermost scope, doesn't have a reference to a parent Environment Record — this is where the scope chain ends.

scope chain

The JavaScript engine, when resolving variables and function calls, follows the Environment Record chain, checking each environment in the chain until it finds the needed variable or function, or until it reaches the global scope. This mechanism ensures access to variables and functions across different levels of nesting.

Let's see the execution context and Environment Record in action. Explore the live examples here.



const apple = '🍏'
const watermelon = '🍉'
const grapes = '🍇'

function makeFruitSalad() {
    const saladBowl = []
    saladBowl.push(apple, watermelon, grapes)

    return saladBowl
}

makeFruitSalad()


Enter fullscreen mode Exit fullscreen mode

example 1

  • As the script is executed, a global execution context is initiated, accompanied by the establishment of its corresponding Environment Record. The variables apple, watermelon, and grapes, along with the function makeFruitSalad are referenced within this Environment Record because they are defined in the global scope.
  • When makeFruitSalad is invoked, a function execution context is created along with its associated Environment Record. The Environment Record includes a reference to the variable saladBowl declared within the function's scope. It also references its outer Environment Record, which is the global Environment Record.

The crucial point is that the function makeFruitSalad has access not only to the variables declared within its scope but also to variables in its outer (enclosing) scope. This is possible through the creation of Environment Records during the execution of the code.

Now that we've understood how the execution context and the Environment Record work, let's dive into closures.

Closures

Think of closures as packages 📦

A closure is the combination of a function bundled together with references to its surrounding state (MDN definition)

The mechanism of closure is what allows functions to access external variables or functions that were in scope when they were defined. Every function in JavaScript holds the potential to be a closure, as they all possess the capability to access variables and functions defined in their outer scope.

Let's go back to our previous example:



const apple = '🍏'
const watermelon = '🍉'
const grapes = '🍇'

function makeFruitSalad() {
    const saladBowl = []
    saladBowl.push(apple, watermelon, grapes)

    return saladBowl
}

makeFruitSalad()


Enter fullscreen mode Exit fullscreen mode

Yes, makeFruitSalad forms a closure 😎

The closure comprises the function makeFruitSalad along with its surrounding state, which includes the variables saladBowl, apple, watermelon, and grapes.

makeFruitSalad's Environment Record stores the value for the saladBowl' variable. It also references its parent Environment Record (the global Environment Record), where the values of the global variables for apple, watermelon, and grapes are stored.

closure in makeFruitSalad

In this case, the variables apple, watermelon, grapes, and the function makeFruitSalad are declared in the global scope so it seems obvious that the function can access these variables. Closures' amazing superpower is not immediately apparent.

Let's consider another example:



function printNumber(number) {

    function print() {
        console.log(number);
    }

    return print;
}

const myPrint = printNumber(10);
myPrint();


Enter fullscreen mode Exit fullscreen mode

This example is a common use case of closure: a function is nested inside an outer function and accesses a variable defined in the outer function.

The inner function print is defined within the outer function printNumber. The nested function print references a variable from its outer scope (number).

example 2

The function print holds a reference to its parent Environment Record, which is the Environment Record of the function printNumber. In turn, the function printNumber holds a reference to its parent Environment Record, which is the global environment. Since the global scope is the outermost scope, it doesn't have a reference to its parent Environment Record.

  • When printNumber(10) is called, an Environment Record is created for the execution of printNumber, storing the value 10 for the variable number.
  • The invocation returns the inner function print, which is assigned to the variable myPrint. At this point, myPrint holds a reference to the print function.
  • Upon calling myPrint(), it executes the inner function print. The closure mechanism allows print to retain access to the number variable, even after the outer function printNumber has finished executing. Hence, the output is 10.

When a function is invoked, a new execution context is created, which includes an Environment Record. The Environment Record is responsible for associating identifiers with their values. In the case of closures, the inner function (in this case print) maintains a reference to its outer function's (in this case printNumber) Environment Record.

When the inner function is defined, it retains access to the variables and functions from its outer scope. This means that even after the outer function completes its execution, the inner function retains access to these outer variables and functions through the reference to the outer function's Environment Record.

Now consider this change: what value is logged this time?



function printNumber(number) {

  function print() {
      console.log(number);
  }

  number = number*2

  return print;
}

const myPrint = printNumber(10);
myPrint();


Enter fullscreen mode Exit fullscreen mode

myPrint() now logs 20 😲

Here the variable number is multiplied by 2 before the print function is returned. Consequently, printNumber's Environment Record now stores 20 as the value for the variable number. Upon invoking myPrint(), the JavaScript engine sequentially accesses its Environment Record and then its outer Environment Record to access the value of the number variable.

The crucial point here is that closures maintain a live reference to their Environment Record. They don't store copies but rather dynamically reference entities defined in their Environment Record.

Consider another example: what do you expect counter.increment() to log each time?



function createCounter() {
  let count = 0;

  function increment() {
    count++;
    console.log("Count:", count);
  }

  count += 10;

  return {
    increment: increment
  };
}

let counter = createCounter();
counter.increment();
counter.increment();


Enter fullscreen mode Exit fullscreen mode

counter.increment() logs 11 and then 12 🤯

createCounter is a function that initializes a variable count with the value 0. Inside createCounter, there is a nested function that increments count and logs the updated value. The variable count is then incremented by 10. The function returns an object with a method increment that refers to the inner function increment.

example 3

  • createCounter is called, and the returned object (with the increment method) is assigned to the variable counter. At this point, the Environment Record for increment includes the variable count, which has been modified to 10 during the execution of createCounter.
  • When counter.increment() is called, it executes the inner function increment. The increment function has access to the variable count from its outer Environment Record, so it increments count and logs the updated value (Count: 11).
  • Calling counter.increment() again continues to use the same outer Environment Record. It increments the count again, and the output will be "Count: 12". The closure mechanism ensures that the inner function retains access to the modified value of count between invocations.

The key point here is that the inner function (increment) retains access to the variables of its outer Environment Record (createCounter). This is why it appears as if the function was "remembering" the previous return value 🔮

Consider this change:



function createCounter() {
  let count = 0;

  function increment() {
    count++;
    console.log("Count:", count);
  }

  count += 10;

  return {
    increment: increment
  };
}

let counter = createCounter();
counter.increment();
counter.increment();
let counter2 = createCounter();
counter2.increment();


Enter fullscreen mode Exit fullscreen mode

counter2.increment() logs 11 and not 13 🙉

Each invocation of createCounter() creates a new Environment Record with its own count variable. Each counter instance "closes over" its own Environment Record, preserving its state between function calls.


Closures in JavaScript are a powerful tool: they leverage lexical scoping to create functions that retain access to outer-scope data. Closures are particularly useful for creating functions with private state or control access to variables.

It's important to note that closures can incur a performance cost. Given that each function utilizing a closure maintains its own Environment Record and references to its parent's Environment Record, the information held by closures must persist in memory until it is no longer referenced.

If closures felt confusing before, I hope they make more sense now. Feel free to reach out if you have any comments or questions! 😊

You can also find me on Github, LinkedIn, and Instagram.

Top comments (0)