Selecting DOM Elements

Introduction

You know the DOM is made of objects. Now you need to find them. Beginners often throw selectors at the problem until one sticks—then the page layout changes and everything breaks.

This lesson teaches you to write selectors that work reliably. You'll understand why some snap under pressure and others don't, and you'll defend your code against the most common failure: querying for something that doesn't exist.

Learning Objectives

By the end of this lesson, you'll be able to:

  • Use CSS selectors to find single and multiple elements
  • Choose the right selector method for the job
  • Handle the "element not found" case without crashing
  • Recognize when selectors are fragile and fix them

Core Concepts

Three Main Selector Methods

document.getElementById() — Find one element by ID (fastest, most specific):

const header = document.getElementById("main-header");

document.querySelector() — Find the first match using CSS selectors:

const firstButton = document.querySelector("button");
const formInput = document.querySelector("input[type='email']");
const cardTitle = document.querySelector(".card .title");

document.querySelectorAll() — Find all matches (returns a NodeList):

const allButtons = document.querySelectorAll("button");
const allRequired = document.querySelectorAll("input[required]");

// Loop through them
allButtons.forEach((btn) => {
  btn.addEventListener("click", handleClick);
});

A NodeList looks like an array but isn't. It has forEach(), but not map() or filter(). Use Array.from() to convert if you need those methods. Array.from(document.querySelectorAll("button")).map(btn => btn.textContent), for example.

Array.from is a top level method which we call by accessing JavaScript's global Array object. It creates a new array from an existing iterable or array-like object (like a NodeList). This is how we convert a NodeList into a true array to use array methods.

CSS Selectors You'll Use Most

Note: CSS selectors are a powerful language for targeting elements and can be as complex or simple as you need them to be. This is where your prerequisite CSS knowledge pays off. If you need a refresher, check out the CSS Selectors chapter on MDN. Here are the most common patterns:

// By tag
document.querySelector("button");

// By class
document.querySelector(".btn");
document.querySelector(".btn.primary"); // both classes

// By ID
document.querySelector("#header");

// By attribute
document.querySelector("input[type='email']");
document.querySelector("[data-user-id='42']");

// Combinations
document.querySelector(".card .title"); // .title inside .card
document.querySelector("button.primary"); // <button class="primary">

Attribute Selector Operators

You can match attributes by pattern using special operators:

// Starts with (^=)
document.querySelector('a[href^="/"]'); // Internal links
document.querySelector('img[src^="https"]'); // HTTPS images

// Ends with ($=)
document.querySelector('a[href$=".pdf"]'); // PDF links
document.querySelector('img[alt$="logo"]'); // Alt text ending with "logo"

// Contains (*=)
document.querySelector('a[href*="example"]'); // Links containing "example"
document.querySelector('[data-test*="user"]'); // Data attributes containing "user"

Do those look strange and different? Actually, they are nothing but CSS selectors, which you already know from styling...theoretically. The only difference is that instead of applying styles, you're getting elements back to work with in JavaScript.

Alternative approach using .getAttribute():

If selector operators feel complex, filter with string methods instead:

// Find all links, check href pattern
const internalLinks = document.querySelectorAll("a");
internalLinks.forEach((link) => {

  // If this is an internal, relative link...
  if (link.getAttribute("href").startsWith("/")) {
    link.textContent += " [internal]";
  }
});

// This produces the same result as: document.querySelectorAll('a[href^="/"]')

Both work—pick whichever reads more clearly to you. The first is more concise; the second is more explicit about intent.

The Data Attribute Hook

Instead of relying on class names (which can change for styling), use data-* attributes as stable anchors:

<div class="card rounded bg-white p-4 shadow" data-product-id="42">
  <h3 class="text-lg font-bold">Coffee Grinder</h3>
  <button class="btn bg-blue-500">Add to Cart</button>
</div>
// Fragile (breaks if classes change):
const addBtn = document.querySelector(".card .btn");

// Stable (explicit intent):
const addBtn = document.querySelector('[data-product-id="42"] .btn');

The 'formula' for a data attribute is data- followed by your custom name. You can choose any name you like after data-, as long as it follows standard attribute naming rules (letters, numbers, hyphens). In the example above, we used data-product-id to uniquely identify the product card. This allows us to target the element in JavaScript without worrying about changes to classes or other attributes that might be used for styling.

NodeList Is Not an Array

querySelectorAll() returns a NodeList, which looks like an array but isn't:

const buttons = document.querySelectorAll("button");

// This works (NodeList has forEach):
buttons.forEach((btn) => console.log(btn)); // ✅ Works directly

// This breaks (NodeList has no map):
buttons.map((btn) => btn.textContent); // ❌ Error: NodeList has no map method

When do you need Array.from()?

Only when you want to use array methods like .map(), .filter(), or .reduce():

// ❌ This breaks - NodeList has no map method
const texts = document.querySelectorAll("button").map((btn) => btn.textContent);

// ✅ Convert to array first with Array.from()
const texts = Array.from(document.querySelectorAll("button")).map(
  (btn) => btn.textContent,
);

// ✅ Or just use forEach - no conversion needed
document
  .querySelectorAll("button")
  .forEach((btn) => console.log(btn.textContent));

For advanced learners: The spread operator

The spread operator (...) is a shorthand that also converts iterables (like NodeList) to arrays:

// Both do the same thing - use Array.from() or spread, whichever feels natural
const texts1 = Array.from(document.querySelectorAll("button")).map(
  (btn) => btn.textContent,
);

const texts2 = [...document.querySelectorAll("button")].map(
  (btn) => btn.textContent,
);

// They produce identical results
console.log(texts1); // ["Button 1", "Button 2", ...]
console.log(texts2); // ["Button 1", "Button 2", ...]

Spread is more concise, but Array.from() is more explicit about intent.

Rule of thumb: If you're just looping and modifying elements, use NodeList directly with forEach(). If you need to transform data into a new collection, convert to array with Array.from() or ....

The Null Check

Most common beginner crash:

// Selector returns null if element doesn't exist
const button = document.querySelector(".missing-button");
button.addEventListener("click", handleClick); // ❌ Error: button is null

Always guard against null. The simplest way to read:

const button = document.querySelector(".missing-button");
if (button) {
  button.addEventListener("click", handleClick); // ✅ Safe
}

This is explicit and easy to understand: if the button exists, add a listener.

Modern shorthand: Optional chaining (?.)

Once familiar with the pattern above, use optional chaining for cleaner code:

const button = document.querySelector(".missing-button");
button?.addEventListener("click", handleClick); // ✅ Safe and concise

The ?. operator says: "If button exists, call addEventListener; if it's null, do nothing." This is the modern, preferred way in production code.

Use whichever feels natural, but aim for ?. as you get comfortable.


Hands-On Practice

Lab 1: Selector Scavenger Hunt

Create an HTML file with:

<header id="main-header">
  <h1>My Site</h1>
  <nav class="navbar">
    <a href="/">Home</a>
    <a href="/about">About</a>
    <a href="https://external.com">External</a>
  </nav>
</header>

<main>
  <form data-form-type="login">
    <input type="email" placeholder="Email" required />
    <input type="password" placeholder="Password" required />
    <button type="submit" class="btn primary">Log In</button>
  </form>

  <div class="card" data-product-id="1">
    <h3 class="card-title">Product A</h3>
    <p class="price">$19.99</p>
  </div>
  <div class="card" data-product-id="2">
    <h3 class="card-title">Product B</h3>
    <p class="price">$29.99</p>
  </div>
</main>

Tasks (write JavaScript to do each):

  1. Select the main header by ID and log it.
  2. Select all links inside .navbar using querySelectorAll and log how many there are.
  3. ⭐ Select internal links (those starting with /) and append " [internal]" to their text.
    • Hint: Use attribute selector operators (^=) or .getAttribute() with .startsWith() from the "Attribute Selector Operators" section above.
  4. Select all required inputs and add a yellow outline (style.outline = "2px solid gold").
  5. Select all .card elements, loop through them, and add a data-viewed="true" attribute to each.
  6. Select the product with data-product-id="2" and change its price to "$24.99".

Guard against null on at least one selector (try targeting something that doesn't exist to practice the guard).

Lab 2: Refactor for Stability

You have this brittle selector:

const title = document.querySelector("main .card .card-title");

Refactor it to use a data attribute instead. Show both versions in a comment.

Hint: You'll need to add a data-* attribute to the HTML first. For example:

<!-- Add this to your HTML -->
<h3 class="card-title" data-title-id="1">Product A</h3>

Then your selector becomes:

const title = document.querySelector('[data-title-id="1"]');

Show your refactored HTML and JavaScript in comments.

Key Takeaways

  1. Use querySelector for flexibility, getElementById when you have an ID.
  2. Data attributes are stable hooks for JavaScript—use them freely.
  3. NodeList ≠ Array—convert with Array.from() if you need array methods.
  4. Always check for null before calling methods on queried elements.
  5. Shorter, intentional selectors outlive fragile chains.