How to select elements by class name in JavaScript

How to select elements by class name in JavaScript

The getElementsByClassName method has been around for a while, and while it has its uses, it can feel a bit cranky compared to newer methods. It returns a live HTMLCollection, which means it updates automatically when the DOM changes. This behavior can lead to some unexpected results, especially if you’re not careful.

Here’s a quick example of how it works:

const elements = document.getElementsByClassName('my-class');
console.log(elements.length); // Outputs number of elements with 'my-class'

// If you add more elements dynamically:
const newElement = document.createElement('div');
newElement.className = 'my-class';
document.body.appendChild(newElement);

console.log(elements.length); // Outputs updated count

The live nature of the HTMLCollection means that if you’re not paying attention, you might end up with a collection that doesn’t represent the state of the DOM at the time you initially queried it. This can be particularly annoying when you’re trying to iterate over the collection or perform operations based on its length.

In contrast, if you were using querySelectorAll, you’d get a static NodeList. This means that the collection you retrieve does not change when the DOM does, providing a more predictable outcome.

Consider this example using querySelectorAll:

const elements = document.querySelectorAll('.my-class');
console.log(elements.length); // Outputs number of elements with 'my-class'

// Adding more elements won't change the length
const newElement = document.createElement('div');
newElement.className = 'my-class';
document.body.appendChild(newElement);

console.log(elements.length); // Still outputs original count

This static behavior allows you to safely loop through the elements without worrying about the collection changing underneath you. It’s a cleaner approach, especially when you’re building applications that involve dynamic DOM updates.

Now, you might be thinking, “Okay, so I use querySelectorAll, but what if I do end up with a live collection from getElementsByClassName? What do I do with it?” Well, here’s where it gets interesting. You can treat that live collection as a standard array-like object, but with a few caveats.

For example, if you want to loop over the collection, you can use a simple for loop, but remember this:

const elements = document.getElementsByClassName('my-class');
for (let i = 0; i < elements.length; i++) {
  console.log(elements[i]); // This will log each element
}

However, if you add or remove elements during this loop, the length of the collection might change, which can lead to skipped elements or even errors. A better approach is to convert the collection into an array first:

const elementsArray = Array.from(document.getElementsByClassName('my-class'));
elementsArray.forEach(element => {
  console.log(element); // Safely logs each element without worrying about DOM changes
});

This conversion gives you the flexibility of array methods, like forEach, while avoiding the quirks of live collections. It’s a small adjustment that can save you from a lot of headaches.

So, if you’re still using getElementsByClassName, it might be time to reconsider your approach. The quirks can lead to unexpected behavior, and there are often cleaner, more efficient ways to achieve the same results.

Why you should just use querySelectorAll already

So you’ve run querySelectorAll and now you’re staring at a NodeList. Congratulations. It looks like an array, it has a length property, you can access elements by index like elements[0]. But try to run map on it and you’ll be greeted with a TypeError. Why? Because it’s not an array. It’s an imposter. A very useful imposter, but an imposter nonetheless.

Thankfully, the people who design browsers aren’t completely evil. Modern NodeList objects come with a forEach method, which is often all you need for simple iteration. It’s a huge improvement over the old days of needing a clunky for loop for everything.

const buttons = document.querySelectorAll('.btn-primary');
buttons.forEach(button => {
  button.style.backgroundColor = 'blue';
  button.textContent = 'Clicked!';
});

But what happens when you need to do something more complex? What if you want to get an array of all the data-id attributes from these elements? Or filter the list to only include buttons that are currently visible? This is where the NodeList shows its limitations. You can’t just call .map() or .filter() because those methods don’t exist on the NodeList.prototype. You’re stuck.

The solution is simple, and you should make it a habit. Convert the NodeList to a proper Array the moment you get it. There are two elegant ways to do this in modern JavaScript, and you should forget that any other way ever existed.

const buttons = document.querySelectorAll('.btn-primary');

// Option 1: Array.from()
const buttonArray1 = Array.from(buttons);

// Option 2: Spread syntax
const buttonArray2 = [...buttons];

// Now you can use all the array methods!
const buttonIds = buttonArray2.map(button => button.dataset.id);
console.log(buttonIds); // ['id-1', 'id-2', 'id-3', ...]

By converting to an array upfront, you’re not just avoiding errors; you’re writing more expressive and functional code. You’re moving away from clunky for loops with index variables and temporary arrays, and into the clean, readable world of map, filter, and reduce. Let’s say you only want to add a click listener to the visible buttons in your collection.

Without converting to an array, you’d have to do some kind of if check inside a forEach loop. With an array, it’s a clean, chainable operation that clearly states its intent.

const allButtons = document.querySelectorAll('.btn');

[...allButtons]
  .filter(button => button.offsetParent !== null) // A simple way to check for visibility
  .forEach(visibleButton => {
    visibleButton.addEventListener('click', () => console.log('Clicked a visible button!'));
  });

This pattern of querying the DOM and immediately converting the result to an array is a powerful technique. It separates the concern of finding elements from the concern of processing them, and it gives you access to a much richer set of tools for the processing part. Once you have a true array, you’re back in familiar JavaScript territory, where you can chain methods and manipulate data with ease. It’s a small step that makes your DOM manipulation code significantly more robust and readable.

So you have a collection of elements now what

So you have a genuine, bona fide array of DOM elements. The shackles are off. You can now do more than just iterate. You can map, filter, and reduce your way to cleaner, more declarative code. Let’s move beyond just changing styles and into something more dynamic: handling user events.

The obvious, brute-force way to add a click handler to a bunch of buttons is to loop through your newly created array and attach a listener to each one. It works, and for three buttons on a page, it’s probably fine. But what if you have a list with 500 items? Attaching 500 separate event listeners is wasteful. Even worse, what happens if you dynamically load more items into that list? Your original script has already run; those new items won’t have the listener. You’d have to remember to re-run your attachment logic every time the DOM changes. It’s a mess.

// The "naive" way. Don't do this for large or dynamic lists.
const items = document.querySelectorAll('.list-item');

[...items].forEach(item => {
  item.addEventListener('click', () => {
    console.log(You clicked item with ID: ${item.dataset.id});
  });
});

// If a new .list-item is added to the DOM later, it won't have this listener.

The professional solution is event delegation. Instead of putting a guard on every single door, you put one guard at the main entrance and give them a list of who’s allowed in. You attach a single event listener to a common ancestor element-the container of your list, for example. Because most events “bubble up” the DOM tree, a click on a list item will also register as a click on its parent container. Inside that single listener, you can then check if the element that was actually clicked (the event.target) is one of the elements you care about.

// The smart way: Event Delegation
const listContainer = document.querySelector('#my-list-container');

listContainer.addEventListener('click', (event) => {
  // Use .closest() to find the list item, even if the user clicked an icon or text inside it
  const clickedItem = event.target.closest('.list-item');

  if (clickedItem) {
    // We found a matching item, so we can proceed.
    console.log(Delegated click for item ID: ${clickedItem.dataset.id});
  }
});

// Now, any new .list-item added inside #my-list-container will work automatically!

This approach is more performant (one listener vs. hundreds) and vastly more robust. It handles dynamically added elements for free. You write the code once, and it just works, no matter how the list changes. This is the kind of fire-and-forget code you should strive to write.

Once you get comfortable with treating a NodeList as a source for a real array, you can unlock even more powerful patterns. Let’s talk about reduce. Many developers see reduce and run for the hills, preferring a familiar for loop. But reduce is purpose-built for boiling a list of items down to a single value, and it does so with elegance.

Imagine you have a shopping cart. Each item is a

with a data-price attribute. You need to calculate the total price. The old-school way involves declaring a total variable, looping through the elements, parsing the price, and adding it to your total. It’s clunky. With an array and reduce, you can express this as a single, self-contained operation.

const cartItems = document.querySelectorAll('.cart-item');

const totalPrice = [...cartItems].reduce((accumulator, currentItem) => {
  // Get the price from the data attribute and convert it to a number
  const price = parseFloat(currentItem.dataset.price);
  
  // Add the current item's price to the accumulator
  return accumulator + price;
}, 0); // The 0 here is the initial value for the accumulator

console.log(Total price: $${totalPrice.toFixed(2)});

Look at how clean that is. There are no temporary variables cluttering the scope. The entire logic for summing the prices is encapsulated in the reduce call. It clearly states its intent: “reduce this list of items to a single number, starting from zero.” This isn’t just about writing fewer lines of code. It’s about adopting a mental model where the DOM is a data source, and you use powerful, standard array methods to transform that data into the result you need.

Comments

No comments yet. Why don’t you start the discussion?

Leave a Reply

Your email address will not be published. Required fields are marked *