Frontend JavaScript Pop Quiz
Hello and Happy Valentine's Day!
A product of doing two years of the #100DaysOfCode Challenge is that my Twitter feed is filled with people from all over the world who engage with that hashtag because they're either learning or teaching JavaScript. Usually this is a good thing, and it exposes me to a lot of great content focused on JS fundamentals.
I came across the following JavaScript problem from one of these Twitter users and it interested me, so I bookmarked it and have had it in my list to write a blog post about it for some time now. However I've struggled to come up with a name or a good way to really engage with it, but I'll take a swing at it now. I'm calling it a Frontend JavaScript Pop Quiz. So, without further ado...
Pop quiz time!
Question 1: What is the output of the following code?
Question 2: Provide one or more alternate implementations that will work as expected.
The code in question:
for (var i = 0; i < 5; i++) { var btn = document.createElement("button"); btn.appendChild(document.createTextNode("Button " + i)); btn.addEventListener("click", function () { console.log(i); }); document.body.appendChild(btn);}
So what do you think? What's going on here? What is the code trying to do? Where is it succeeding? Where is it failing? How can we fix that failure? I highly encourage you to take a moment to examine this code on your own and try to address what's wrong with it.
What is the Output of the Code?
As you can probably tell, this is some frontend JavaScript that's working within the DOM (Document Object Model), meaning it's manipulating the elements within an HTML file that's been loaded within a web browser. I think what's assumed here is that within an HTML file, we have a <body></body>
element that is empty except for the JavaScript code above:
<!DOCTYPE html><html lang="en"> <head> <meta charset="UTF-8" /> <meta http-equiv="X-UA-Compatible" content="IE=edge" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>Frontend JavaScript Pop Quiz</title> </head> <body> <script> for (var i = 0; i < 5; i++) { var btn = document.createElement("button"); btn.appendChild(document.createTextNode("Button " + i)); btn.addEventListener("click", function () { console.log(i); }); document.body.appendChild(btn); } </script> </body></html>
So now let's focus on the JavaScript code itself and what it's doing within that HTML document.
The entirety of the code happens with in a for
Loop. This loop initializes its counter, i
, at 0
, and on each iteration increments i
by 1
until i
is no longer less than 5
. In short, the loop runs 5 times, so the code within the {}
block runs 5 times.
The first statement within the loop block is var btn = document.createElement("button");
This creates a <button></button>
element within the document, and saves it to the variable btn
.
The second statement within the loop block is btn.appendChild(document.createTextNode("Button " + i));
This creates a Text Node, the contents of which will be shown within the newly created button. That content will be "Button " + i
, where i
is the value of the counter variable on that iteration of the loop. For the first iteration, the Text Node's value (and thereby the text within the button) will be "Button 0". On the second iteration, that value will be "Button 1". Then "Button 2", "Button 3", and finally "Button 4".
The third statement is:
btn.addEventListener("click", function () { console.log(i);});
This statement adds an Event Listener to each button, listening specifically for any click actions that occur on any of the buttons. If a button gets clicked on, then the anonymous function passed as the second argument to the .addEventListener()
method will fire. More on this in a second.
The final statement, document.body.appendChild(btn);
simply appends each newly created button to the document body. This means that the five button elements are appended to the end of the <body></body>
element, which looks like this:
<button>Button 0</button><button>Button 1</button><button>Button 2</button><button>Button 3</button><button>Button 4</button>
But let's go back to that btn.addEventListener
line again:
btn.addEventListener("click", function () { console.log(i);});
With the 5 buttons (Button 0 through Button 4) getting rendered to the page, we should test out this Event Listener. In theory if we click on Button 0, then the console.log(i)
expression within the callback function should simply log 0
, right? Similarly, if we click on Button 3, then console.log(i)
should log 3
.
Unfortunately that's not what happens. Instead, with the code written as-is, we get 5
logged to the console no matter which button gets clicked. Button 0? 5
. Button 1? 5
. Button 4? 5
.
So to answer the first question, What is the output of the following code? We are rendering out five buttons, numbered sequentially 0 through 4, and while we are hoping that if we click on a button and see its number logged to the console (i.e., click on Button 3 and get 3
in the console), we are getting 5
logged to the console for all of the buttons.
So why is this happening? And how would we fix it? To understand the fix we need to understand the underlying issues and address them one by one.
Provide one or more alternate implementations that will work as expected.
Part of the issue is that when we call i
within the Event Listener, it's getting called well after the loop has finished running. Since we used var
to declare the i
variable (and because we aren't working in Strict Mode), i
is available as a global variable within the window
. Type i
into the console and you'll see 5
.
This brings up a couple of interesting observations and questions. Firstly, if the condition within the for
Loop was i < 5
, shouldn't i
actually be 4
, since that was its value on the last iteration of the loop? The answer to this is actually no. The loop continues to increment the counter until the exit condition is met. So once i
is the value 5
, the condition is not met any more so the loop stops running and the contents within the {}
block aren't run.
The other issue is that we have now polluted the window
with a variable, i
, that we don't need after the loop has finished running. So let's clear that up. The first modification I'll make to the code is to change the for
Loop from:
for (var i = 0; i < 5; i++) { // button creation, text node stuff, etc.}
to:
for (let i = 0; i < 5; i++) { // button creation, text node stuff, etc.}
This is a small change to the code, but it has big consequences. If you try to console.log(i)
now outside of the loop, you'll get a ReferenceError
telling you that i
is not defined. This is good.
So now how do we go about getting the number logged out that corresponds to each button. The first thing I'm going to do is remove the Event Listener from inside of the loop. To me, the loop should only serve to actually create the elements. Once they're created we can add Event Listeners to the elements on the page.
for (let i = 0; i < 5; i++) { const btn = document.createElement("button"); btn.appendChild(document.createTextNode(`Button ${i}`)); document.body.appendChild(btn);}
So here we have our loop that's creating our buttons and giving them the correct text content to each button. One other small change is I switched from the clunky concatenation syntax we saw previously (document.createTextNode("Button " + i)
) and used an ES6 Template Literal instead: document.createTextNode(`Button ${i}`)
. It produces the same result: "Button 0", "Button 1", etc.
Before I move on from the loop, I do want to add one other piece that will help make extracting the correct number from each button element much easier. We could take something like the outerText
of that element, which would maybe look like "Button 1"
, and do some string manipulation to drop the "Button "
part of that text. That would leave us with the 1
that we want to log. However, to make things a little easier (and I think more programmatically proper) I'm actually going to add the number for each button as a dataset attribute:
for (let i = 0; i < 5; i++) { const btn = document.createElement("button"); btn.appendChild(document.createTextNode(`Button ${i}`)); btn.setAttribute("data-value", i); document.body.appendChild(btn);}
I've added the following statement: btn.setAttribute("data-value", i);
. This simply creates an attribute on each of the buttons called "data-value", and the value of each of these attributes is i
.
So now if we look at the buttons being rendered in the DOM we will see:
<button data-value="0">Button 0</button><button data-value="1">Button 1</button><button data-value="2">Button 2</button><button data-value="3">Button 3</button><button data-value="4">Button 4</button>
Don't worry if you're not totally following this, just trust me that this is setting us up nicely for success.
Our for
Loop is finished and our buttons are showing up how we want them, now all we need to do is attach the Event Listener to each button and console.log()
the correct number when a button is clicked.
To do that we first need to do a Query Selector for all buttons on the page:
const buttons = document.querySelectorAll("button");
This gives us a NodeList of the buttons on the page. We can iterate over this NodeList using .forEach()
and attach the Event Listener:
const buttons = document.querySelectorAll("button");buttons.forEach((button) => { button.addEventListener("click", (e) => { console.log(e); // Big old click event object });});
Now if we click on one of the buttons, we get a huge object that is the click event itself. This object has all kinds of useful and interesting information, but we're most interested in the target of the click. If we look into e.target
, we'll see that one of the properties available is called dataset
, and within dataset
we'll see a property called value
, and its value will be the number of the button that we clicked on. This is the beauty of setting data-
attributes on document elements: we can create custom property-value pairs on DOM elements, giving them whatever name and value we want, and that information is easily accessed within something like a click event! Very cool.
Anyway at this point it's just a matter of logging out e.target.dataset.value
within the click event:
const buttons = document.querySelectorAll("button");buttons.forEach((button) => { button.addEventListener("click", (e) => { console.log(e.target.dataset.value); // Big old click event object });});
So the final code is:
for (let i = 0; i < 5; i++) { const btn = document.createElement("button"); btn.appendChild(document.createTextNode(`Button ${i}`)); btn.setAttribute("data-value", i); document.body.appendChild(btn);}
const buttons = document.querySelectorAll("button");buttons.forEach((button) => { button.addEventListener("click", (e) => { console.log(e.target.dataset.value); });});
With this code, we're still creating and appending the five buttons, and when we click on any of the buttons we get its number logged to the console.
To recap, this solution eliminates the variable pollution on the window
object that we saw in the initial code. By separating the Click Event handler functionality out from the for
Loop, we are leaving the for
Loop to only be concerned with creating buttons. Lastly, we leverage the power of dataset attributes, binding the values we are looking for directly to their respective elements. This makes it much easier for an Event Listener to extract the correct data from the event target, rather than trying to figure out the correct data within a for
Loop.
Thank you for reading this blog post! I hope it was helpful. I should also say that this is just one way to tackle this challenge. If you have a different way of solving this, I'd love to see it! Or if you find something wrong with my code. Simply reach out to me at joey@joeyreyes.dev.