Mastering JavaScript Closures: A Deep Dive ✨
JavaScript closures
are one of the most stubborn concepts to grasp. You may use closures
in your code a lot without realizing that you are using them.
A lot of credit goes to Will Sentance and Frontend master teaching the course JavaScript: The Hard Parts by platform.
To understand closures, we first need to understand execution context.
What is execution context?
- Execution context: In JavaScript, the term “execution context” refers to the environment in which a piece of code is executed. It consists of variables, functions, objects, and other data that a piece of code has access to during runtime.
Types of Execution context:
- Global Execution Context: This is the default or outermost execution context. It’s created when your script is executed, and it’s where code that’s not inside any function is executed. In a web browser, the global execution context is associated with the
window
object. - Function Execution Context: Each time a function is called, a new
execution context
is created for that function. This context includes all thevariables, objects, and functions defined within the function
. When the function finishes executing, its execution context is popped off theexecution stack (call stack)
.
Function Execution Overview
When a function is called, JavaScript performs a series of well-defined steps to ensure the function executes correctly. Here’s a detailed breakdown of the process:
- Start: Creation of the Execution Context
- Execution Context: When a function is called, JavaScript creates a new execution context specifically for that function. This context contains all the necessary parameters, variables, and the function’s code.
- Call Stack: This new execution context is then pushed onto the
call stack
, which is a mechanism JavaScript uses to manage function invocation order.
- Execution: Line-by-Line Processing
- Line-by-Line Execution: Within the function’s
execution context
, JavaScript processes the function's code line by line. This involves handling variables, performing calculations, and executing any other operations defined within the function. - Scope Chain and Closures: During execution, the function has access to its own local variables, the parameters passed to it, and any variables from its outer scope, thanks to closures.
- End: Cleanup and Context Removal
- Popping off the Call Stack: Once the function has completed execution, its execution context is popped off the call stack. This means the context is removed, and JavaScript returns to executing the code from where the function was called.
- Local Variable Cleanup: The local variables and parameters defined within the function’s execution context are destroyed, freeing up memory and resources.
An example for clarity:
let’s break down the execution of the following function in a detailed manner:
1: const num = 3;
2: function multiplyBy2(inputNumber) {
3: const result = inputNumber * 2;
4: return result;
5: }
6: const output = multiplyBy2(num);
7: const newOutput = multiplyBy2(10);
8: console.log(output);
9: console.log(newOutput);
- Line 1: We declare a new constant variable
num
in the global execution context and assign it the number3
. - Lines 2–5: Here, we declare a new function named
multiplyBy2
. We create a new variable namedmultiplyBy2
in the global execution context, and we assign a function definition to it. This function takes a parameterinputNumber
and contains code to multiply it by 2, store the result in a variableresult
, and returnresult
. The code inside the function is not evaluated yet, just stored for future use. - Line 6: This line looks simple but has a lot happening. First, we declare a new variable in the global execution context and label it
output
. Initially, this variable isundefined
. - Line 6 (continued): We are about to assign a new value to
output
by calling the functionmultiplyBy2
. The JavaScript engine looks upmultiplyBy2
in the global execution context, finds the function definition, and prepares to execute it. The variablenum
is passed as an argument. The engine findsnum
in the global execution context with the value3
and passes this value to the function. - New execution context for multiplyBy2: A new local execution context is created, which we’ll call the
multiplyBy2 execution context
. This context is pushed onto the call stack. The first thing to do here is to handle the function parameters. A new variableinputNumber
is declared in this local execution context, and it is assigned the value3
(the argument passed). - Line 3: Within the local execution context, we declare a new constant variable
result
. Initially,result
isundefined
. Then, the expressioninputNumber * 2
is evaluated. The engine looks upinputNumber
, finds it in the local execution context with the value3
, and multiplies it by2
, resulting in6
. This value is then assigned toresult
. - Line 4: We return the value of
result
, which is6
. The local execution context ends here. VariablesinputNumber
andresult
are destroyed. The context is popped off the call stack, and the return value (6
) is returned to the calling context (global execution context). - Line 6 (continued): The returned value (
6
) is assigned tooutput
. - Line 7: Similarly, we declare another variable
newOutput
in the global execution context and assign it the result of callingmultiplyBy2
with the argument10
. The engine again findsmultiplyBy2
, calls it, and passes10
as the argument. - New execution context for multiplyBy2 (second call): A new local execution context is created and pushed onto the call stack. The parameter
inputNumber
is assigned the value10
. - Line 3: In this local execution context, a new constant variable
result
is declared and initialized toundefined
. The expressioninputNumber * 2
is evaluated. The engine findsinputNumber
with the value10
, multiplies it by2
, resulting in20
, and assigns this value toresult
. - Line 4: The function returns
result
, which is20
. The local execution context ends, variablesinputNumber
andresult
are destroyed, and the context is popped off the call stack. The return value (20
) is returned to the calling context. - Line 7 (continued): The returned value (
20
) is assigned tonewOutput
. - Line 8: We log the value of
output
to the console. The console displays6
. - Line 9: We log the value of
newOutput
to the console. The console displays20
.
This detailed explanation illustrates how JavaScript handles variable declarations, function definitions, and the creation and destruction of execution contexts. We haven’t touched on closures yet, but this sets the stage for understanding them by showing the basics of how functions and their scopes work.
you can use something like this to see code execution: JavaScript Visualizer (ui.dev)
Let’s run some code:
As we have learnt about how a function executes from the previous example. Let’s take a look a this function and try to figure out what will happen.
1: function createCounter() {
2: let counter = 0
3: const myFunction = function() {
4: counter = counter + 1
5: return counter
6: }
7: return myFunction
8: }
9: const increment = createCounter()
10: const c1 = increment()
11: const c2 = increment()
12: const c3 = increment()
13: console.log('example increment', c1, c2, c3)
let’s break down the execution of the createCounter
function in detail, step by step:
- Lines 1–8: We start by defining a function named
createCounter
. In the global execution context, we declare a new variablecreateCounter
and assign it the function definition that spans lines 1 through 8. The code inside the function is not executed yet, just stored for future use. - Line 9: We declare a new variable
increment
in the global execution context. Initially, this variable isundefined
. - Line 9 (continued): We call the
createCounter
function and assign its returned value toincrement
. The JavaScript engine looks upcreateCounter
in the global execution context, finds the function definition, and prepares to execute it. - New execution context for createCounter: A new local execution context is created, named the
createCounter execution context
. This context is pushed onto the call stack. - Line 2: Inside this local context, we declare a variable
counter
and initialize it to0
. - Lines 3–6: We declare a new constant variable
myFunction
and assign it a function definition. This function will increment thecounter
variable and return its value. - Line 7: The
myFunction
function is returned bycreateCounter
. The local execution context forcreateCounter
ends, and the context is popped off the call stack. The variablescounter
andmyFunction
no longer exist in this context. - Line 9 (continued): The returned function (
myFunction
along with its closure) is assigned to the variableincrement
. Now,increment
contains the function definition that was stored inmyFunction
. - Line 10: We declare a new variable
c1
in the global execution context. - Line 10 (continued): We call the
increment
function and assign its return value toc1
. The JavaScript engine looks upincrement
, finds the function definition, and prepares to execute it. - New execution context for increment: A new local execution context is created for this function call, named the
increment execution context
. This context is pushed onto the call stack. - Line 4: Inside this local context, we update
counter
by incrementing it by1
. We look for counter in the local execution context but don’t find it. So, we look in the global context and Still no counter. JavaScript interprets this ascounter = undefined + 1
and creates a new local variable counter with the value 1 (since undefined acts like 0 here). - Line 5: The function returns the value of counter, which is 1. This ends the local execution context, and counter is destroyed.
- Line 10 (continued): The returned value (
1
) is assigned toc1
. - Line 11: We repeat steps 9–14 for
c2
. Theincrement
function is called again, This time again1
is returned and assigned toc2
. - Line 12:We repeat steps 9–14 for
c3
. Theincrement
function is called again, This time again1
is returned and assigned toc3
. - Line 13: Finally, we log the values of
c1
,c2
, andc3
to the console. The console displaysexample increment 1 1 and 1
.
Try running the code yourself and see what happens. You might expect it to log 1
, 1
, and 1
based on the explanation above, but instead, it logs 1
, 2
, and 3
. What’s going on here?
It turns out the increment
function somehow remembers the value of counter
. How does that work?
Is counter
part of the global execution context? Try console.log(counter)
and you’ll see undefined
. So that’s not it.
Maybe when you call increment
, it somehow goes back to the function where it was created (createCounter
)? But that doesn’t make sense because the variable increment
contains the function definition, not the context it came from. So that’s not it either.
There must be another mechanism at play here.
And there is: “The Closure”. This is the missing piece.
Here’s how it works: Whenever you declare a new function and assign it to a variable, you store not just the function definition but also a closure. The closure contains all the variables that are in scope when the function is created. It’s like a backpack. A function definition comes with a little backpack that stores all the variables that were in scope at the time the function was created.
So, when you call increment
, it’s not just using the function definition—it’s also using the variables that were in scope when the function was defined. That’s why counter
keeps its value between calls to increment
. It’s not part of the global execution context, but it’s remembered because of the closure.
So our explanation above was All wrong, let’s try it again, but correctly this time.
1: function createCounter() {
2: let counter = 0
3: const myFunction = function() {
4: counter = counter + 1
5: return counter
6: }
7: return myFunction
8: }
9: const increment = createCounter()
10: const c1 = increment()
11: const c2 = increment()
12: const c3 = increment()
13: console.log('example increment', c1, c2, c3)
let’s break down the execution of the createCounter
function in detail, step by step:
- Lines 1–8: We start by defining a function named
createCounter
. In the global execution context, we declare a new variablecreateCounter
and assign it the function definition that spans lines 1 through 8. The code inside the function is not executed yet, just stored for future use. - Line 9: We declare a new variable
increment
in the global execution context. Initially, this variable isundefined
. - Line 9 (continued): We call the
createCounter
function and assign its returned value toincrement
. The JavaScript engine looks upcreateCounter
in the global execution context, finds the function definition, and prepares to execute it. - New execution context for createCounter: A new local execution context is created, named the
createCounter execution context
. This context is pushed onto the call stack. - Line 2: Inside this local context, we declare a variable
counter
and initialize it to0
. - Lines 3–6: We declare a new constant variable
myFunction
and assign it a function definition. This function will increment thecounter
variable and return its value. Importantly, a closure is created here. The closure includes thecounter
variable, capturing its current value (0
) within the function definition. - Line 7: The
myFunction
function (including its closure) is returned bycreateCounter
. The local execution context forcreateCounter
ends, and the context is popped off the call stack. The variablescounter
andmyFunction
no longer exist in this context. However,myFunction
retains access tocounter
via its closure. - Line 9 (continued): The returned function (
myFunction
along with its closure) is assigned to the variableincrement
. Now,increment
contains the function definition that was stored inmyFunction
. - Line 10: We declare a new variable
c1
in the global execution context. - Line 10 (continued): We call the
increment
function and assign its return value toc1
. The JavaScript engine looks upincrement
, finds the function definition, and prepares to execute it. - New execution context for increment: A new local execution context is created for this function call, named the
increment execution context
. This context is pushed onto the call stack. The first thing we do in this local context is use the closure. - Line 4: Inside this local context, we update
counter
by incrementing it by1
. Sincecounter
is in the closure, its value is retrieved (which is0
), incremented to1
, and updated in the closure. - Line 5: The function returns the updated value of
counter
, which is1
. The local execution context forincrement
ends, and the context is popped off the call stack. - Line 10 (continued): The returned value (
1
) is assigned toc1
. - Line 11: We repeat steps 9–14 for
c2
. Theincrement
function is called again, the closure'scounter
value (1
) is incremented to2
, and2
is returned and assigned toc2
. - Line 12: We repeat steps 9–14 for
c3
. Theincrement
function is called again, the closure'scounter
value (2
) is incremented to3
, and3
is returned and assigned toc3
. - Line 13: Finally, we log the values of
c1
,c2
, andc3
to the console. The console displaysexample increment 1 2 3
.
So, what’s happening here? The key is that when a function is declared, it contains not just the function definition but also a closure. The closure is a collection of all the variables that were in scope at the time the function was created.
You might wonder if any function has a closure, even functions created in the global scope. The answer is yes. Functions created in the global scope do have a closure, but since they’re in the global scope, they have access to all the global variables, making the closure concept less relevant.
Closures become really important when a function returns another function. The returned function has access to variables that aren’t in the global scope but exist in its closure.
In summary, the increment
function remembers the value of counter
through its closure. Each call to increment
updates and returns the counter
value, demonstrating how closures allow functions to retain access to their own lexical scope even after the function that created them has finished executing. This is the essence of closures in JavaScript: they enable functions to "remember" the environment in which they were created.
Conclusion
There is a great analogy made by Will Sentance in the course:
When a function gets created and passed around or returned from another function, it carries a backpack with it. And in the backpack are all the variables that were in scope when the function was declared.
I hope you understand closures now. Thank you🙌