DEV Community

Gohomewho
Gohomewho

Posted on

Debounce is a good example of closure

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()
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

We can use a variable to store what's returned from calling counter().

const c = counter()
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
})
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

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)
})
Enter fullscreen mode Exit fullscreen mode

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)
  }
}
Enter fullscreen mode Exit fullscreen mode

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)
})
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)