Debounce is a good example of closure
Gohomewho
Posted on July 17, 2022
I can't remember how many times I learn a new topic and left with confusion. If I come across a same topic, I'll try to learn it again. Often, it's not because we don't understand it. it's because we don't know the practical use case. We don't know how to apply it, therefore, we think we don't understand.
In programming, there are so many abstract concepts. Closure is an example that I've tried to learn so many time from different resources. Last time when I was learning it again, and I found that I've been using it without realizing it.
What is closure?
This is a definition of closure from w3schools.
A closure is a function having access to the parent scope, even after the parent function has closed.
Let's forget about closure for a moment.
Here we define a function called counter
and define a variable num
. We then define another function inside counter
called add
. Inside add
, we can access the num
defined in the parent scope. Next, we call add()
.
function counter(){
let num = 0
function add() {
num++
console.log('num is: ', num)
}
add()
}
We can open the devtools and run the code in console to quickly examine if something works. Let's call counter
function a few times.
counter()
// num is: 1
counter()
// num is: 1
counter()
// num is: 1
We can confirm that num
defined inside parent scope conuter
can be accessed inside child scope add
because num
is properly logged in the console.
Let's modify the example above. This time we don't call add()
directly inside counter
. We return the function add
.
function counter(){
let num = 0
function add() {
num++
console.log('num is: ', num)
}
return add
}
We can use a variable to store what's returned from calling counter()
.
const c = counter()
We know that counter
returns a function. So we know c
is a function. Let's call c()
a few times. We can see that num
is increasing
c()
// num is: 1
c()
// num is: 2
c()
// num is: 3
Previously, when we call counter()
a few times, the results are the same num is: 1
. That's because we always create a new num
, add one to it, and log it.
When we call counter()
, we can imagine that we create a new context, and the context is returned along with add
. Here we only call counter()
once. We store the function returned from counter()
inside a variable c
, so c
is the function add
inside counter
. We knew that add
have access to num
and what it does.
After counter()
is closed and c
is returned, c
still have access to the context that was created. That's why calling c()
can increase num
.
function counter(){
let num = 0
function add() {
num++
console.log('num is: ', num)
}
return add
}
const c = counter()
c()
// num is: 1
c()
// num is: 2
c()
// num is: 3
Let's recap the definition from w3schools again.
A closure is a function having access to the parent scope, even after the parent function has closed.
When we define a function that returns a function, that's a closure!
If you can understand the output of this code, then you already understand closure.
const c1 = counter()
const c2 = counter()
// calling c1
c1() // num is: 1
c1() // num is: 2
c1() // num is: 3
// calling c2
c2() // num is: 1
How to use a closure
You've probably learned closure before. Learning from the example like above again doesn't seem to help. That's what I felt! Why do we need closure? It seems to make things more complicated. Like I mentioned in the beginning, we have this feeling often because we don't see practical use case. Let's try another example.
For example, we are building a search feature. We listen to the input event, so every time users enter or remove something, the event will be triggered.
const input = document.querySelector('input')
input.addEventListener('input', (e)=>{
const query = e.target.value
// send request to fetch resources
// this is pseudo code
fetchResources(query)
})
If someone is searching for 'apple', each input trigger a request.
// each input send a request
'a'
'ap'
'app'
'appll' // make a typo
'appl' // remove a typo
'apple'
As a result, we will send more requests than we need to. To solve this, we can use a strategy called debounce to reduce the amount of requests we send to server.
You can learn more about debounce from this arttcle by Josh Comeau
Let's add debounce to our search feature. we define a variable timeout
. We wrap fetchResources(query)
inside a setTimeout
, so a request is not sent immediately when a event is triggered. We also store the action of setTimeout
to timeout
, so it can be canceled later. The delay is set to 200ms, so when another event is triggered between 200ms, clearTimeout(timeout)
will cancel the previous action. Then setTimeout
will create a new one.
const input = document.querySelector('input')
let timeout
input.addEventListener('input', (e)=>{
const query = e.target.value
// send request to fetch resources
clearTimeout(timeout)
timeout = setTimeout(() => {
fetchResources(query)
}, 200)
})
Now our code becomes clunky and we have a let
variable timeout
that can be modified anywhere. Luckily, we have a better way to do the same thing which is also safer and cleaner.
We extract the logic of debounce to a function. Its first parameter accepts a callback short as cb(a function). Its second parameter is how much time we want to delay the action. Calling debounce()
will return a new function that accepts arbitrary arguments ...args
. Those arguments will be stored in an array. They will be passed to our action function cb(...args)
.
function debounce(cb, delay = 1000) {
// the returned function have access to parent scope
// what is accessible here can be accessed inside the returned function
let timeout
// return a function - closure
return (...args) => { // ...args collect arguments as an array
clearTimeout(timeout)
timeout = setTimeout(() => {
cb(...args) // ...args spread the array elements as arguments
}, delay)
}
}
You can learn more about ...
Rest parameters and Spread syntax on MDN.
We call debounce(fetchResources,200)
create a debounced version of fetchResources
with 200ms delay. debouncedFetchResources
will be called when input event is triggered. But because it is a debounced version, it will only run after user stop triggering input event after 200ms, just like what we did above.
const input = document.querySelector('input')
// create a debounced version of fetchResources with 200ms delay
const debouncedFetchResources = debounce(fetchResources,200)
input.addEventListener('input', (e)=>{
const query = e.target.value
// send request to fetch resources
debouncedFetchResources(query)
})
If we want to debounce something else later, we can use that debounce function again. We don't need to define new variables like timeout2
or timeout3
to store other actions because an independent timeout
variable is created when we called debounce()
each time. We don't need to write extra clearTimeout()
and setTimeout()
. We also don't need to worry about accidentally modifying the timeout
variables somewhere because they are hidden in the closure. Only the returned functions have access to their respective timeout
variables.
If you just learn debounce and don't understand what it does, that's totally fine. The example here is try to make you understand what a closure can do, not a specific use case of closure.
Wrap up
Sometimes we learn an abstract topic, we don't know how to apply it. Thus, we think we don't understand it. But it is not true. The idea lives in our head. We'll recognize it later when we encounter something. So Next time if you learn something new but have no idea where to apply it. Don't worry too much about it! We don't need to create a problem and solve it. We'll solve problems when we need to.
Posted on July 17, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
July 19, 2022