Mastering closures in JavaScript
Taha Shashtari
Posted on March 20, 2024
Closures are one of the most confusing concepts for new JavaScript learners. They are very powerful. Once you understand them well, your JavaScript skills will move to the next level.
In this article, I will give you all the knowledge you need to understand closures well. We will start with defining what scoping is and the different types of scoping. Then, we will learn the different ways to define variables in JavaScript. And we will move in small steps until we see how closures are defined. At the end of this article, I will show 5 different practical applications of closures that you can use now in your JavaScript code.
What is scoping?
In simple terms, scoping is the rules that determine how a variable (or function or class) is evaluated and accessed.
For example, we say that this variable's scope is some function or some if statement, and sometimes we say that its scope is global, meaning we can access it and modify it from anywhere.
Here are a couple of scoping examples.
// a is global variable
var a = 5
function fun() {
// b's scope is only within the function fun
let b = 'foo'
if (b === 'foo') {
// c's scope is only within this if statement
let c = 'bar'
}
}
Static (lexical) vs. dynamic scoping
We always hear that JavaScript is a lexical scoping language, but it's not really clear what that means.
To understand what that means, we need to understand the alternative: dynamic scoping.
In some old languages, like Lisp and Bash, dynamic scoping was used. However, most modern languages stopped using it because dynamic scoping can lead to unpredictable behavior.
It can lead to that because variable scopes are determined based on the calling context at runtime. Let me make this clear with an example.
Take a look at this code:
let v = 10
// Called by b()
function a() {
return v
}
// b() has its own variable named as v and calls a()
function b() {
let v = 20
return a()
}
console.log(b())
If I asked you what value console.log(b())
will log, it will be an obvious answer: 10.
It's 10 because function a()
returns v
, and v
is defined above and has the value 10.
That's how static (also called lexical) scoping works. You can know the value just by looking at the code.
If dynamic scoping were what JavaScript uses, the logged value would be 20. It would be 20 because when running the code, function b()
would override the scope used for the variable v
to the one defined in b()
, and that would make a()
use the last one, which is 20.
Thankfully, JavaScript doesn't use dynamic scoping and uses lexical scoping instead.
So you can simply think of lexical scoping as the scoping that allows us to know what variable will be used just by looking at the code at compile time (if the language is compiled) and not at runtime.
let
and const
vs. var
Advice everyone gets when learning JavaScript is to always use let
and const
instead of var
. But why is that? Is var
bad? Not necessarily.
It's all about scoping.
let
and const
are block scoped, while var
is function scoped. What does that mean?
It means that any variable declared with let
or const
will be available only within the block it was defined in. By block, I mean {}
(curly braces).
This literally means this:
let a = 'foo'
{
let b = 'bar'
console.log(b) // Output: 'bar'
}
console.log(b) // Error: undefined
Look how I defined a block even without any additional keyword like if
or for
. And that's a valid syntax, but in real-world code, the blocks are usually preceded with some keywords like when defining if statements, while loops, and functions.
This is why when we use let
and const
in if statements, for example, we can't use the variable outside of it.
if (true) {
// Here is a new scope (inner scope)
const a = 'foo'
}
// Here is the outer scope (another one)
console.log(a) // Error: undefined
So console.log
logged undefined
because it's in a different scope from the if statement. This means you can define the variable a
again in the outer scope without affecting the one in the inner scope.
if (true) {
const a = 'foo'
}
const a = 'bar'
console.log(a) // bar
So that's how let
and const
scoping works. Let's see how var
works.
var
is for defining function-scoped variables. This means it will create a new scope only if it was used within a function, like this:
function foo() {
var a = 'foo'
}
console.log(a) // Error: undefined
Note that var
is only function scoped (not block scoped), which means defining it within an if statement, for example, will define it globally (on the window
object if in the browser).
if(true) {
var a = 'foo'
}
console.log(a) // foo
So var
will define a new scope if it's used in a function; otherwise, it will define the variable in the global scope.
Thus, we get the advice to prefer let
and const
over var
because block-scoped variables are more predictable and easier to reason about.
Access direction
An important rule to remember in lexical scoping: inner scopes can access outer scopes, but not the other way around.
Here's an example:
let a = 1
// cannot access b or c
{
// can access a
// cannot access c
let b = 2
{
// can access a
// can access b
let c = 3
}
}
You can think of it as: you can't look inside things, but you can look outside them.
You need to always remember this rule as closures heavily depend on it as you will see next.
It also works for functions
Until now, we've been looking at examples that show how scoping works for variables. But the same is true for functions.
Let's take a look at this example:
function a() {
function b() {
console.log('from b')
}
b()
console.log('from a')
}
b() // Error: b is not defined
It errors because function b
is not defined within the scope of the caller.
If we modify the example to call a()
instead, it will work because a
is defined within the scope of the caller.
function a() {
function b() {
console.log('from b')
}
b()
console.log('from a')
}
a()
// Output:
// from b
// from a
Also note how it was able to call function b
from function a
because b
is defined within the scope of a
.
In this example, we have nested functions, so we are really close to the concept of closures (get ready).
Returning a function from another function
Let's take the previous example and modify it to return the function b
from the function a
.
function a() {
return function b() {
console.log('from b')
}
}
const nestedFunction = a()
nestedFunction() // Output: from b
Notice how we were able to overcome the restriction of scopes by returning the function. In the previous example, we weren't able to call function b
directly in the outer scope, but when we returned the function b
, we were able to access it and call it.
Let's stop for a moment here to talk about the idea of returning a function.
When in a language a function can return another function (or take functions as arguments), we say two things:
- this language supports functions as first-class citizens
- and we call that function a Higher-order function (HoF).
Actually, this is why we say JavaScript supports functional programming.
One more step to create a closure
Let's take the previous example and make function b
use (return) a variable defined in function a
.
function a() {
let v = 10
return function b() {
return v
}
}
const nestedFunction = a()
console.log(nestedFunction()) // Output: 10
Believe it or not, you have created your first closure.
Here's the simplest definition of a closure:
A closure is a function that uses and retains access to a variable defined outside of its scope.
Two important things to note here: uses and retains access.
The word uses here means that the function can use the outside variables without the need to pass them as parameters.
And retains access means that the inner function will still have access to the outside variables even after the outer function finishes executing.
So in the example above, even after the function a()
finished executing, the variable v
was not removed by the garbage collector because the inner function b()
might need to use it in the future.
A small note here: a function doesn't have to use outside variables to be considered a closure; it's still called a closure even if it's empty, but that's not a common thing we see.
Closures can also modify outer variables
Not only can closures access outer variables, but they can also modify them if they were defined with let
.
function a() {
let counter = 0
return function b() {
return counter++
}
}
const nestedFunction = a()
console.log(nestedFunction()) // Output: 0
console.log(nestedFunction()) // Output: 1
console.log(nestedFunction()) // Output: 2
You can return multiple closures from a function
This is not a feature specific to closures, but it's a feature of JavaScript that is worth mentioning.
Let's modify the previous example to return two functions instead of one by returning an object.
function makeCounter() {
let counter = 0
return {
inc() {
return ++counter
},
getValue() {
return counter
}
}
}
const counter = makeCounter()
console.log(counter.inc()) // Output: 1
console.log(counter.inc()) // Output: 2
console.log(counter.inc()) // Output: 3
console.log(counter.getValue()) // Output: 3
It's still the same idea, but this is how we usually return multiple closures.
Why closures?
Like anything in programming, there are many different ways to implement something. And closures are no different. While anything can be implemented without closures (for example, in languages that don't support closures), closures provide a much simpler and more intuitive way to do so.
The first case that comes to mind for closures in JavaScript is event handling. Let's take a look at this example:
let counter = 0
button.addEventListener('click', () => {
counter++
console.log(counter) // 1 .. 2 .. 3 ..
})
While this is a simple example, there are a few things to unpack:
- We have two scopes here: outer scope (where
counter
is defined), and inner scope for the callback defined inaddEventListener
. - Since the inner function has access to the outer scope, we consider it a closure.
- This function gets called every time the user clicks the button.
- Every time it's called, it increments the outer variable
counter
and logs it.
Look how easy event handling is because of closures. Because the closure maintains access to its outer scope, we were able to modify and log counter
on each click.
This example shows us how closures are everywhere, and we might be using them without realizing.
There are other cases where you want to create closures intentionally. And here are five different real-world examples of closures.
Example 1: Encapsulation
Like in Object-oriented programming (OOP), we can achieve encapsulation through closures. Encapsulation essentially means hiding part of the data and the internal implementation.
In OOP, we can do this using the private
keyword. In closures, we can simply do that by defining the data outside the closure, like this:
function makeCounter() {
// Private data
let counter = 0
// Private function
function a() {}
// Public
return {
inc() {
counter++
},
dec() {
counter--
},
getCounter() {
return counter
}
}
}
const counter = makeCounter()
counter.inc()
console.log(counter.getCounter())
In this example, there's no way you can access counter
or the function a()
directly. You can only do that through its public API; in other words, the object returned from it.
Example 2: Partial application
In mathematics, partial application means fixing a certain number of arguments in a function to create a new function with fewer arguments.
If that's a little bit confusing, don't worry. Here's a simpler explanation.
Let's say you have a function f(a, b, c)
. This function takes three arguments: a, b, and c. Partial application means creating another function from function f
where some arguments are hardcoded. For example, we can have a new function called g(b, c)
, where a
is always the value 10, for example.
We can do the same in JavaScript because functions are first-class citizens.
Let's take a look at this example:
function add(a, b) {
return a + b
}
function add10(b) {
return add(10, b)
}
console.log(add10(5)) // Output: 15
In this example, we created another function called add10
based on the original one add
. The new function does the same but always uses the number 10 as the first argument.
This example is just for demonstration purposes, so it might not look useful. Let's take a look at a useful example that you might use in your project.
function createFormatter(prefix, suffix) {
return function (string) {
return `${prefix}${string}${suffix}`
}
}
const bracketsFormatter = createFormatter('[', ']')
const paragraphFormatter = createFormatter('<p>', '</p>')
console.log(bracketsFormatter('name')) // [name]
console.log(paragraphFormatter('some text')) // <p>some text</p>
You can see how we were able to create different types of formatters using the general one createFormatter
. If I want to create another formatter that surrounds text with curly braces, I just need to do this:
const curlyFormatter = createFormatter('{', '}')
So, with partial applications, we can make a certain function more reusable and flexible by fixing some of its arguments. All thanks to closures for enabling us to do that.
Example 3: Validation functions
In this example, I want to create a way to validate values. If they are valid, don't do anything; otherwise, throw an error. With closures, we can do that easily.
function createValidator(validatorFunction, errorMessage) {
return function (value) {
if (!validatorFunction(value)) {
throw new Error(errorMessage)
}
}
}
const positiveValidator = createValidator((value) => {
return value > 0
}, 'The provided number must be positive')
try {
positiveValidator(-10) // will throw an error
} catch (error) {
console.log('error', error)
}
positiveValidator
is just one example. You can use createValidator
to create any validator you want.
Example 4: Throttling
Another good application of closures is throttling, where a function is called only once per some duration.
function throttle(callback, duration) {
let lastExecuted = 0
return function (...args) {
const now = Date.now()
if (now - lastExecuted >= duration) {
callback(...args)
lastExecuted = now
}
}
}
const saveRequest = throttle((data) => {
console.log('saving data', data)
}, 1000)
// Only one request is sent
saveRequest({ user: 'test' })
saveRequest({ user: 'test' })
As an exercise, see how you can modify the throttle
function to be a debounce
function.
Example 5: Memoization
Memoization simply means caching the result of some expensive function.
In the following example, I'm writing a memoization function for addition, but in the real world, it will be something more expensive.
function memoizedAdd() {
const cache = {}
function addFunc(a, b) {
console.log('adding')
return a + b
}
return function (a, b) {
const key = `${a},${b}`
if (cache[key]) {
return cache[key]
}
const result = addFunc(a, b)
cache[key] = result
return result
}
}
const add = memoizedAdd()
console.log(add(10, 5))
console.log(add(10, 5))
console.log(add(10, 1))
// Output:
// adding
// 15
// 15
// adding
// 11
You can see how the word adding
was logged only once for adding 10 and 5, since the second time used the cached result. Imagine how beneficial this would be for expensive operations.
Conclusion
Closures are a fundamental part of JavaScript, and we are already using them every day without realizing—just like in the event handling example.
Understanding them well helps us write code that's more reusable and flexible. In this article, I've shown you five different examples of how closures can be used, but there's no limit to what you can come up with.
Whenever you forget what closures are, just remember that they're basically functions that remember their surroundings.
There are countless applications of closures. Are there any that haven't been mentioned in this article? Please share them in the comments section.
🔗 Let's stay connected:
- 🌐 Blog: https://tahazsh.com/
- 🎥 YouTube: https://www.youtube.com/@tahazsh
- 𝕏 Twitter/X: https://twitter.com/tahazsh
Posted on March 20, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
November 24, 2024