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!).
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.
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()
- As the script is executed, a global execution context is initiated, accompanied by the establishment of its corresponding Environment Record. The variables
apple
,watermelon
, andgrapes
, along with the functionmakeFruitSalad
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 variablesaladBowl
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()
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.
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();
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
).
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 ofprintNumber
, storing the value 10 for the variablenumber
. - The invocation returns the inner function
print
, which is assigned to the variablemyPrint
. At this point,myPrint
holds a reference to theprint
function. - Upon calling
myPrint()
, it executes the inner functionprint
. The closure mechanism allowsprint
to retain access to thenumber
variable, even after the outer functionprintNumber
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();
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();
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
.
-
createCounter
is called, and the returned object (with theincrement
method) is assigned to the variablecounter
. At this point, the Environment Record for increment includes the variablecount
, which has been modified to 10 during the execution ofcreateCounter
. - When
counter.increment()
is called, it executes the inner functionincrement
. Theincrement
function has access to the variablecount
from its outer Environment Record, so it incrementscount
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 ofcount
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();
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! 😊
Top comments (0)