DEV Community

mattIshida
mattIshida

Posted on

Closure and event listeners

Closure is notorious as one of the hardest concepts for Javascript beginners to pick up. That would be fine if it surfaced only rarely or in advanced applications. But closure plays a huge (and under-appreciated) role in Web Development 101. If you've ever added an event listener to handle a button click or a form submission like so: myElement.addEventListener('click', callbackFunction), you've likely been leveraging the power of closure.

Let's dive in.

Introducing closure

Closure is a hidden superpower of functions in Javascript and other programming languages. When thinking about closure, the mantra that I repeat to myself over and over again is:

Functions retain access to variables in the context in which they were created.
Functions retain access to variables in the context in which they were created.
Functions retain access to variables in the context in which they were created.
...

Set aside the technical details for a second. More on those to come. One of the biggest practical takeaways, especially for the budding developer, is that functions can, if created in the right context, access data that might seem "lost." You may have neglected to store a piece of data by assigning it to a global variable, but it's possible that one of your functions did not.

The killer use case for this in basic web development is achieving front-end persistence without a lot of awkward global variables. But first let's go deeper into what closure is.

A non-web example of closure

A classic simple illustration of closure begins with function that returns another function.

let count = 0;
function outer(a){
  function inner(){
    count++;
    console.log(count)
    return a * count;
  }
  return inner;
}
const ourFunction = outer(5)
ourFunction() // =>1
ourFunction() // =>2
ourFunction() // =>3
Enter fullscreen mode Exit fullscreen mode

Here we are defining a function outer that itself defines a function called inner and returns it. inner increments the global variable count, console.logs it, multiplies it by the value of the parameter a, and finally returns that result.

When we call outer with the argument 5, it defines a function in the global scope named ourFunction. So ourFunction returns the next multiple of 5 each time it is called. Moreover, repeatedly calling ourFunction will log the value of count incremented by 1 each time. So ourFunction seems to be doing a useful service not only of counting by five but also of tracking the number of times it has been invoked.

All of this should be relatively straightforward. Since count is a global variable, it can be accessed from inside outer and for that matter from inside inner as well!

But count is maybe a little too accessible. What if we had some friends who wanted to use outer to do their own counting by 7s. We could try something like this.

let count = 0;
function outer(a){
  function inner(){
    count++;
    console.log(count)
    return a * count;
  }
  return inner;
}
const ourFunction = outer(5)
ourFunction() // =>1
ourFunction() // =>2
ourFunction() // =>3

const theirFunction = outer(7)
theirFunction() // =>4
theirFunction() // =>5
theirFunction() // =>6

ourFunction() // => 7
Enter fullscreen mode Exit fullscreen mode

The global variable count now gets incremented by both ourFunction and theirFunction. The first time theirFunction gets called it logs 4 and returns 28. We have lost the desirable feature of counting up smoothly by 5s or 7s and of tracking the number of times each specific function was invoked.

What we would like to have is a count variable specific to ourFunction and another count variable specific to theirFunction.

We could define two global variables, ourCount and theirCount. But what if we wanted to count by 9s, 11s, 13s, and so on? We could create a new global variable each time, but then we could wind up with hundreds or thousands.

What to do?

We know from Javascript 101 that invoking a function creates its own execution context. Variables defined in that context are not accessible once the function returns a value: if you declare myVar within a function, you don't then get to access myVar outside that function. But this seems to deepen the mystery. Shouldn't a variable be either global (which won't work for us) or else confined to fleeting execution context where we can't get to it?

Let's go back to our mantra:

Functions retain access to variables in the context in which they were created.

Unpacking this, we know:

  1. ourFunction retains access to variables in the context in which it was created.
  2. It was created in the execution context of the function outer.
  3. So any variable declared in the scope of outer would be accessible to ourFunction.

Wouldn't that same variable be accessible to theirFunction as well? Well, no, not the same variable, but a similar variable with the same name, because each time outer is invoked it defines a new execution and count is declared anew in that context and functions declared in the same context would have access to it! This seems to be exactly what we want.

This line of thought suggests that we simply declare count inside outer.

function outer(a){
  let count = 0;
  function inner(){
    count++;
    console.log(count)
    return a * count;
  }
  return inner;
}
Enter fullscreen mode Exit fullscreen mode

And indeed this gives us what we want.

const ourFunction = outer(5)
ourFunction() // =>1
ourFunction() // =>2
ourFunction() // =>3

const theirFunction = outer(7)
theirFunction() // =>1
theirFunction() // =>2
theirFunction() // =>3

ourFunction() // => 4
Enter fullscreen mode Exit fullscreen mode

Each time we invoke outer to create a function, we are also creating a locally-scoped count variable that inner, which is created in the same context, can access. We are creating a count variable that is enclosed within that function.

Achieving front-end persistence

This may all seem like a curiosity or a programming exercise. How often do we actually define functions within functions?

It turns out to be surprisingly common. Any time you have a set of elements on a website--images, buttons, names in a list--and want to add the same functionality to each one, you are like to define a function that iterates over each element. And within that function you are like to call the addEventListener method which itself takes a function, the so-called callback, has an argument. So for each element, you are declaring a function to be executed in response to a certain event, and you are doing that from within another function. Exactly the situation where closure can shine!

For example, what if you wanted to be able to track clicks or other events the same we tracked the number of times ourFunction was invoked? It should be no surprise that closure can do the job.

A simple voting app

To make this more concrete, let's consider a simple website for tracking votes (clicks).

Note: We're dealing with front-end persistence only here, so "tracking" for our purposes means tracking within a single session. Any data will disappear when the page reloads.

Color voting website with lightpink background

Here the user can select three color options for which to vote as many times as they wish.

When you click the lightpink button, the background changes to lightpink and you can click the voting button to vote for lightpink. A counter of the current votes is displayed.

Similarly, when you click the lightblue button, the same thing happens. The background changes and you can now vote for lightblue and see a count of votes for lightblue.

Color voting website with lightblue background

Ditto for lightgreen.

Color voting website with lightgreen background

Now the question: how do we get the vote counts for each color to persist such that votes for each color are counted accurately no matter how many times we switch between colors, voting as we wish for each? Three votes for lightgreen should stay three votes when I switch to light green, no matter how many times I vote for lightpink or lightblue in the interim.

Answer: closure.

The problem is really not so different from tracking invocations of ourFunction and theirFunction.

Code

Code for this section is on github

First we can create the bare bones of the voting website by iterating over an array of colors and calling a function on each.

const colors = ['lightpink', 'lightblue', 'lightgreen']
colors.forEach(addColorToDOM)
Enter fullscreen mode Exit fullscreen mode

We simply need to define the addColorToDOM function.

The function needs to do three things:

  1. Add each color button to the page
  2. Add functionality that changes the background color and displays votes in response to a click on the button
  3. Counts votes (i.e. clicks on the voting button) correctly

The first item is straightforward. For each color we simply generate a new DOM element, set its text content, and append it to the correct HTML element, which we've assigned to the variable colorList.

// DOM selectors
const colorList = document.querySelector('#color-list')

// Render functions
function addColorToDOM(color){
    const choiceButton = document.createElement('button')
    choiceButton.textContent = color
    colorList.append(choiceButton)
}
Enter fullscreen mode Exit fullscreen mode

The second is a bit more complicated but still pretty easy. We simply add a click event listener to our choiceButton element.

// DOM selectors
const colorList = document.querySelector('#color-list')

// Render functions
function addColorToDOM(color){
    const choiceButton = document.createElement('button')
    choiceButton.textContent = color
    colorList.append(choiceButton)

    choiceButton.addEventListener('click', () => {
        document.body.style.backgroundColor = color
    })
}
Enter fullscreen mode Exit fullscreen mode

Nice.

The click also needs to display the votes, so let's go ahead and declare a voteCount variable initialized to 0 and inject that content into the appropriate DOM element as well.

// DOM selectors
const colorList = document.querySelector('#color-list')
const votes = document.querySelector('#votes')

// Global variables
let voteCount = 0

// Render functions
function addColorToDOM(color){
    const choiceButton = document.createElement('button')
    choiceButton.textContent = color
    colorList.append(choiceButton)

    choiceButton.addEventListener('click', () => {
        document.body.style.backgroundColor = color
        votes.textContent = `Current votes for ${color}: 
         ${voteCount}`
    })
}
Enter fullscreen mode Exit fullscreen mode

So far, so good. Now we have only have the third task of figuring out how to set voteCount correctly for each color.

As a first step, observe that each color should listen for clicks on the vote button, but only count votes if that color is the currently selected color. So we'll need to add an event listener on the voteButton element, plus a variable to track which color is currently selected

// DOM selectors
const colorList = document.querySelector('#color-list')
const votes = document.querySelector('#votes')
const voteButton = document.querySelector('#vote-btn')

// Global variables
let voteCount = 0
let currentSelection

// Render functions
function addColorToDOM(color){
    const choiceButton = document.createElement('button')
    choiceButton.textContent = color
    colorList.append(choiceButton)

    choiceButton.addEventListener('click', () => {
        currentSelection = color
        document.body.style.backgroundColor = color
        votes.textContent = `Current votes for ${color}: 
         ${voteCount}`
    })

    voteButton.addEventListener('click', ()=>{
        if(currentSelection === color) {
            voteCount++;
            votes.textContent = `Current votes for ${color}: 
            ${voteCount}`
        }
    })
}
Enter fullscreen mode Exit fullscreen mode

We also need to modify the choiceButton callback to update the currentSelection variable. I've gone ahead and included those modifications in the code above as well.

OK. It looks like we have all the pieces we'll need. Let's go ahead and test.

I can easily add votes for lightpink.

votes for lightpink

But as soon as I select lightblue the counter starts where lightpink left off and increments from there. FAIL.

incorrect votes for lightblue

Hmm... we set up event listeners on each button with callbacks that increment the same global variable voteCount. What we actually need is a voteCount specific to each.

This is exactly the situation we faced with ourFunction and theirFunction. There too we had a count variable that both functions could access, and we needed a variable that was specific to each. We solved the problem by declaring the count variable in the unique execution context where each function was created and let closure do its thing.

Let's do the same here. We simply move the declaration of voteCount from the global context to inside the addcolorToDOM function.

// DOM selectors
const colorList = document.querySelector('#color-list')
const votes = document.querySelector('#votes')
const voteButton = document.querySelector('#vote-btn')

// Global variables
let currentSelection

// Render functions
function addColorToDOM(color){
    const choiceButton = document.createElement('button')
    choiceButton.textContent = color
    colorList.append(choiceButton)

    let voteCount = 0

    choiceButton.addEventListener('click', () => {
        currentSelection = color
        document.body.style.backgroundColor = color
        votes.textContent = `Current votes for ${color}: 
         ${voteCount}`
    })

    voteButton.addEventListener('click', ()=>{
        if(currentSelection === color) {
            voteCount++;
            votes.textContent = `Current votes for ${color}: 
            ${voteCount}`
        }
    })
}
Enter fullscreen mode Exit fullscreen mode

Now, when addColorToDom gets called on a color, it creates a new execution context and a new voteCount variable. And we know from our mantra

Functions retain access to variables in the context in which they were created.

that the callback functions created in the same execution context will retain access to those variables, even when the execution context goes away.

When we test out the solution, it does indeed work to count votes for each color and persist them when you switch back and forth.

I can start logging votes for lightpink:

persisting votes for lightpink

Switch to lightblue:

persisting votes for lightpink

And to lightgreen:

persisting votes for lightpink

Vote counts are accurate and remain so even when I toggle back and forth to my heart's content.

Takeaway

We were able to achieve front-end persistence without any data structure that explicitly logged votes for each of the three colors. Instead, we were able to enclose a color-specific voteCount variable within each callback function. Every time the callback function got invoked it retained access to its own voteCount variable.

In fact the connection between closure and event listeners goes even deeper than what I've discussed here, but I'll leave that for another post.

Top comments (0)