(This article is part of an ongoing series on soft skills and technical wizardry from Nathan Thomas, a software engineer working in San Francisco. Click here for the previous article in the series, "Building Your First GraphQL Server." Click here for the next article in the series, a piece called "Building Your First GraphQL Server.")
Explaining closures is a frequently-used JavaScript interview question, but many engineers still don’t fully understand how they work. There’s a fog that surrounds it, much like the feeling you get when you eat too much during the holidays. 🥧
When I first started learning closures, my first impulse was to close my laptop screen and go stare existentially at the local pond. But don’t worry; closures trip up a lot of people, but you’ll understand how they work and the concepts behind them by the end of this article.
I’m going to walk you through a really cool (and more importantly, simple) example. You'll get it in no time at all.
Grab your favorite coffee, apple cider, hot chocolate, or wintery beverage, and let’s get started. ☕️
"I'll use any excuse to buy a new backpack." - Michael Potts
Closures are an interesting concept. They are truly invisible in your code, and yet they are very real. If you've written any JavaScript, you've almost certainly used them without even realizing it.
If nothing else, that should give you comfort; if you're already accidentally using them successfully, half the battle will just be learning the ideas behind them to fill in the gaps of how they work.
At a very base level, a closure is an invisible compartmentalization of variables that are accessible to your code at various levels (e.g. the global scope, local function scope, etc.).
Each time you write a function, a closure is created for the scope available to it. JavaScript does this automatically, so all we need to do is understand it.
For instance, let's say we have the following code:
const globalVariable = 1;
function createClosure() {
const localVariable = 2;
return function() {
return globalVariable + localVariable;
}
}
const closure = createClosure();
console.log(closure()); // returns 3
This code will log the number
3
to the console. JavaScript has created multiple scopes for your code behind the scenes; there is a global scope with the globalVariable
in it and local scopes with the localVariable
and globalVariable
accessible in them.Here's the same code with comments included to indicate which parts are global and local scopes:
// global scope with globalVariable available
const globalVariable = 1;
function createClosure() {
// local scope with localVariable and globalVariable available
const localVariable = 2;
return function() {
// local scope with localVariable and globalVariable available
return globalVariable + localVariable;
}
}
const closure = createClosure();
console.log(closure()); // returns 3
Stop and think about what's happening here for a moment; the createClosure function can access both variables.
What if I told you that this ability of closures to access all variables in scope at time of the function's creation could be used later when the function is used elsewhere, even another file?
I know. Has science gone too far?
Let's find out. 🧪
“A good traveler has no fixed plans and is not intent on arriving." - Lao Tzu
Here's the code we're going to be working through for the rest of the article. Go ahead and read through it. 👍
Don't worry if you don't understand how it relates to closures yet. Just get an idea of what code is actually doing:
function createCountingClosure(startingNum) {
if (typeof startingNum !== "number") return null;
let num = startingNum;
function increment() {
num++;
return num;
}
function decrement() {
num--;
return num;
}
function getNum() {
return num;
}
return [increment, decrement, getNum];
}
When we invoke that function, here's what we get out of it:
const [increment, decrement, getNum] = createCountingClosure(0);
Furthermore, we can invoke those
increment
, decrement
, and getNum
functions to get the following:increment(); // 1
increment(); // 2
increment(); // 3
decrement(); // 2
increment(); // 3
getNum(); // 3
Whoa.
Don't worry. We're about to work through this code in detail to figure out exactly what's going on.
(Side note - We're doing some destructuring here. If you don't know what destructuring is, no worries. Check out MDN's excellent article on it here.)
Let's look at that
createCountingClosure
function again and break down each part:function createCountingClosure(startingNum) {
if (typeof startingNum !== "number") return null;
let num = startingNum;
function increment() {
num++;
return num;
}
function decrement() {
num--;
return num;
}
function getNum() {
return num;
}
return [increment, decrement, getNum];
}
First, we're taking in an argument called
startingNum
. We are clearly expecting it to be a number (as the next line, an if statement, returns null
if it isn't).Next, we're assigning the value of
startingNum
to the num
variable. This gives our counter function a starting value to work with and change later.We then proceed to declare three functions:
increment
, which increases the value of num
and returns its valuedecrement
, which decreases the value of num
and returns its valuegetNum
, which returns the current value of num
Finally, we return these three functions inside an array. As functions are "first class citizens" in JavaScript, we can pass functions around like this to be returned from our function and used elsewhere.
This is why, later on, we can invoke createCountingClosure and destructure out these three functions:
const [increment, decrement, getNum] = createCountingClosure(0);
As you can see, we're also passing
0
into our function. Remember how we set it up to accept a startingNum
argument? That value is going to be 0
in this example.With all of that out of the way, we're now at the part of discussing the
createCountingClosure
function where we can talk about how the closures work.Hold on to your butts.
Let's take a look at the
createCountingClosure
function again:function createCountingClosure(startingNum) {
if (typeof startingNum !== "number") return null;
let num = startingNum;
function increment() {
num++;
return num;
}
function decrement() {
num--;
return num;
}
function getNum() {
return num;
}
return [increment, decrement, getNum];
}
When we declared
increment
, decrement
, and getNum
, we unknowingly created little closure "backpacks" for each of them that store access to the all variables available in their scope when these functions were created.Remember at the start of this article how we had global and local scope? Remember how the function we created had a local scope that retained access to the
globalVariable
in the global scope?Well, the same thing is happening here. For instances, the
increment
function retains access to the num
variable. In fact, all of these functions can still access this variable later on anywhere that we might use them (even if it's in a different file).For example, remember how we destructured out all of these functions when we invoked
createCountingClosure
?const [increment, decrement, getNum] = createCountingClosure(0);
Here's what the
increment
function has access to (in code) at the time it's destructured out as we did above:let num = startingNum;
function increment() {
num++;
return num;
}
Even though we've only destructured out
increment
, it still has access to the num
variable. It's in the function's closure backpack, along for the ride like a tiny little Yoda.What this means is that we can access and modify our
num
variable even though we don't have it directly inside the function itself.Isn't that wild? 🦁
This same thing is true for
decrement
and getNum
, too. They both have access to num
in the same manner through their closure from when they were created.Let's go ahead and use our code to see how this works.
"I would gladly live out of a backpack if it meant I could see the world." - Unknown
We're going to use our code and walk through it step by step to observe what it does. Here's a refresh of the entire thing before we get started:
function createCountingClosure(startingNum) {
if (typeof startingNum !== "number") return null;
let num = startingNum;
function increment() {
num++;
return num;
}
function decrement() {
num--;
return num;
}
function getNum() {
return num;
}
return [increment, decrement, getNum];
}
In addition, we're going to invoke our
createCountingClosures
function and destructure out the functions returned in the array like we did previously:const [increment, decrement, getNum] = createCountingClosure(0);
With this setup, we're now ready to go. If you want to play around with this while we walk through the rest of this article, here's a codepen link that will allow you to interact with the code.
First off, we're going to invoke the increment function:
increment(); // should return 1
As we've previously discussed, the
increment
function retains access to the num variable. When we invoke increment
, the function will increase the value of num
behind the scenes.Since we passed in the value of
0
when we initially invoked createCountingClosures
up above, we increment that value to 1
. (If you're following along in the CodePen I provided, you'll see that pop up in the console because I've wrapped the function call in a
console.log
.)Next, we'll invoke increment two more times like so:
increment(); // should return 2
increment(); // should return 3
Notice that the value being returned each time is not starting at
1
; this is because the same variable num
that we used above is also retained by increment
and modified each time it is invoked. This is the incredible power of closures!Next, we're going to call the function
decrement
to decrease the value of num
:decrement(); // should return 2
Notice that the value is, once again, not starting at
0
. The functions decrement
and increment
both retain access to the same num
variable and share the ability to modify it even though it is not contained inside either of them.Finally, we're going to call
increment
one more time and cap it off by calling the getNum
function (which just returns the current integer value of num
):increment(); // should return 3
getNum(); // 3
All of these function calls have access to the num variable as all of them have the reference to it stored away in their little scope "backpack."
The final
getNum
call returns 3, which is the value of num
from all of the many changes we've made to it. Even though num
is not contained directly inside any of the functions we've been invoking, we've modified it through the power of closures.If you're still having some trouble grasping how closures work, here's an excellent short video from MPJ (who runs the Fun Fun Function channel on YouTube) about how closures work:
I hope that this brief overlook of how closures work helps you grasp them.
By imagining them as little backpacks that functions can wear as they move around your application, you can build a framework in your mind for how you can access and manipulate variables contained inside of closures.
Also, now that we've stepped through how they work, you can be confident in your closure knowledge and crush your next job interview.
Thanks for reading. 🔥
Nathan
(Twitter, LinkedIn, GitHub, and Portfolio Site)