Selecting and Manipulating DOM Elements in JavaScript

Selecting and Manipulating DOM Elements in JavaScript

Selecting and Manipulating DOM Elements in JavaScript

Learning to select and manipulate DOM elements was, for me, the moment JavaScript stopped being an abstract exercise and started actually building things I could see on screen. Every dynamic website I’ve ever built — from simple form validation to complex single-page applications — comes down to selecting elements and changing them. In this article, I’ll walk through everything I use daily, along with the internal details that help explain why certain approaches perform better than others.

Selecting Elements

querySelector and querySelectorAll

These are the two methods I reach for almost exclusively now, because they accept any valid CSS selector, which I already know from styling.

const firstButton = document.querySelector("button");
const specificEl = document.querySelector("#main-header");
const firstItem = document.querySelector(".list-item");
const nested = document.querySelector("ul.menu > li.active");

const allButtons = document.querySelectorAll("button");
const allItems = document.querySelectorAll(".list-item");

Legacy Selection Methods

These older methods still exist and are still used, especially getElementById for performance-sensitive lookups, and it’s worth knowing the differences.

const byId = document.getElementById("main-header"); // no # prefix needed
const byClass = document.getElementsByClassName("list-item"); // live HTMLCollection
const byTag = document.getElementsByTagName("li"); // live HTMLCollection
MethodReturnsLive or StaticSelector Type
getElementByIdSingle element or nullN/AID only
getElementsByClassNameHTMLCollectionLiveClass name
getElementsByTagNameHTMLCollectionLiveTag name
querySelectorSingle element or nullN/AAny CSS selector
querySelectorAllNodeListStaticAny CSS selector

The “live vs. static” distinction matters a lot in practice:

const liveItems = document.getElementsByClassName("item");
const staticItems = document.querySelectorAll(".item");

console.log(liveItems.length, staticItems.length); // e.g., 3, 3

document.body.insertAdjacentHTML("beforeend", '<div class="item"></div>');

console.log(liveItems.length);   // 4 — automatically updated
console.log(staticItems.length); // 3 — unchanged, since it's a snapshot

I use getElementsByClassName/getElementsByTagName only when I specifically want a live-updating collection; otherwise, querySelectorAll is far more flexible.

Traversing the DOM

Once I have a reference to an element, I often need to navigate to its relatives.

const item = document.querySelector(".list-item");

console.log(item.parentElement);       // direct parent element
console.log(item.children);            // HTMLCollection of direct child elements
console.log(item.nextElementSibling);  // next sibling element
console.log(item.previousElementSibling); // previous sibling element
console.log(item.closest(".container")); // nearest ancestor matching selector

closest() is one I use constantly, especially in event delegation, since it walks up the tree from an element (including itself) until it finds a match.

Manipulating Content

textContent vs. innerHTML vs. innerText

const heading = document.querySelector("h1");

heading.textContent = "New Title"; // safest, treats content as plain text
heading.innerHTML = "New <em>Title</em>"; // parses as HTML, can render tags
console.log(heading.innerText); // similar to textContent, but respects rendered styling (like display:none)
PropertyParses HTML?Triggers Reflow for Hidden ElementsSecurity Risk
textContentNoNoSafe
innerHTMLYesNoXSS risk if inserting untrusted content
innerTextNoYes (reads rendered text)Safe

I use textContent by default for setting plain text, and reserve innerHTML only for trusted, sanitized content, because setting innerHTML with user-provided data is one of the most common sources of Cross-Site Scripting (XSS) vulnerabilities.

// DANGEROUS if userInput comes from an untrusted source:
heading.innerHTML = userInput;

// SAFE:
heading.textContent = userInput;

Creating and Inserting Elements

const newItem = document.createElement("li");
newItem.textContent = "New task";
newItem.classList.add("list-item");

const list = document.querySelector("#todoList");
list.appendChild(newItem);          // adds to the end
list.prepend(newItem);              // adds to the beginning
list.insertBefore(newItem, list.children[1]); // inserts at a specific position

Modern Insertion Methods

list.append(newItem, "some text");  // can append multiple nodes/strings at once
list.before(newItem);                // inserts before the list itself, as a sibling
list.after(newItem);                 // inserts after the list itself

insertAdjacentHTML for Bulk HTML Insertion

list.insertAdjacentHTML("beforeend", "<li>Another task</li>");
PositionMeaning
beforebeginBefore the element itself
afterbeginJust inside the element, before its first child
beforeendJust inside the element, after its last child
afterendAfter the element itself

Removing Elements

const oldItem = document.querySelector(".list-item.completed");
oldItem.remove(); // modern, simple

// Legacy way:
oldItem.parentElement.removeChild(oldItem);

Working with Attributes

const link = document.querySelector("a");

link.setAttribute("href", "https://example.com");
console.log(link.getAttribute("href")); // https://example.com
link.removeAttribute("target");
console.log(link.hasAttribute("href")); // true

For standard attributes with direct property equivalents, I often use the property directly for simplicity:

link.href = "https://example.com";
link.id = "main-link";

Data Attributes

// <div id="card" data-user-id="42" data-role="admin"></div>
const card = document.querySelector("#card");

console.log(card.dataset.userId); // "42" (camelCase conversion from kebab-case)
console.log(card.dataset.role);   // "admin"

card.dataset.status = "active";   // adds data-status="active" to the element

Working with Classes

const box = document.querySelector(".box");

box.classList.add("active");
box.classList.remove("hidden");
box.classList.toggle("selected");
console.log(box.classList.contains("active")); // true
box.classList.replace("box", "container");

I use classList almost exclusively over directly manipulating className as a string, since it avoids manual string parsing and accidental duplicate class names.

Working with Styles

const panel = document.querySelector(".panel");

panel.style.backgroundColor = "navy";
panel.style.display = "flex";
panel.style.setProperty("--custom-color", "teal"); // for CSS custom properties

const computed = window.getComputedStyle(panel);
console.log(computed.backgroundColor); // the actual rendered value

I generally prefer toggling CSS classes over setting individual inline styles directly, since it keeps styling logic in CSS files and JavaScript logic focused on state, but direct style manipulation is useful for computed, dynamic values (like a progress bar’s width based on a percentage).

progressBar.style.width = `${percentComplete}%`;

Internal Working: The DOM as a Tree and Reflow/Repaint

The DOM is a tree structure the browser builds from parsed HTML. Every time I modify it — adding an element, changing text, altering a style that affects layout — the browser may need to recalculate the layout (called reflow) and redraw pixels on screen (called repaint or paint). Reflow is the more expensive operation because it can cascade — changing the width of one element may shift the position of many others.

This is why batching DOM changes matters for performance. Reading a layout-triggering property (like offsetHeight) immediately after writing one (like changing style.width) forces the browser to do a synchronous reflow right then, instead of waiting to batch it with other changes — a pattern known as “layout thrashing.”

// BAD: causes layout thrashing (repeated forced reflows)
elements.forEach((el) => {
  el.style.width = el.offsetWidth + 10 + "px"; // read then write, per element, in a loop
});

// BETTER: read all values first, then write all changes
const widths = elements.map((el) => el.offsetWidth);
elements.forEach((el, i) => {
  el.style.width = widths[i] + 10 + "px";
});

DocumentFragment for Efficient Batch Insertion

When inserting many elements, I use a DocumentFragment — an in-memory, lightweight container that isn’t part of the visible DOM — to build everything first, then insert it in a single operation, minimizing reflows.

const fragment = document.createDocumentFragment();

for (let i = 0; i < 1000; i++) {
  const li = document.createElement("li");
  li.textContent = `Item ${i}`;
  fragment.appendChild(li);
}

document.querySelector("#bigList").appendChild(fragment);
// Only ONE reflow happens here, instead of 1000 separate ones

Practical, Real-World Applications

Building a Simple To-Do List

const form = document.querySelector("#todoForm");
const input = document.querySelector("#todoInput");
const list = document.querySelector("#todoList");

form.addEventListener("submit", (e) => {
  e.preventDefault();
  if (!input.value.trim()) return;

  const li = document.createElement("li");
  li.textContent = input.value;

  const deleteBtn = document.createElement("button");
  deleteBtn.textContent = "×";
  deleteBtn.addEventListener("click", () => li.remove());

  li.appendChild(deleteBtn);
  list.appendChild(li);
  input.value = "";
});

Toggling a Dark Mode Theme

const toggle = document.querySelector("#themeToggle");

toggle.addEventListener("click", () => {
  document.body.classList.toggle("dark-mode");
  localStorage.setItem("theme", document.body.classList.contains("dark-mode") ? "dark" : "light");
});

if (localStorage.getItem("theme") === "dark") {
  document.body.classList.add("dark-mode");
}

Best Practices

Common Mistakes to Avoid

  1. Using innerHTML with unsanitized user input — a direct XSS vulnerability.
  2. Querying the DOM repeatedly inside loops instead of caching the reference once.
  3. Causing layout thrashing by alternating reads and writes of layout-affecting properties in a loop.
  4. Forgetting that querySelectorAll returns a static snapshot, and expecting it to reflect later DOM changes.

Debugging Tips

Security Considerations

I never insert untrusted user input via innerHTML without sanitizing it first (using a library like DOMPurify, or by using textContent when HTML isn’t actually needed). This single habit prevents the vast majority of client-side XSS vulnerabilities I might otherwise introduce.

FAQs

Q: What’s the difference between children and childNodes? A: children returns only element nodes; childNodes includes all node types, including text nodes and comments.

Q: Why does getElementsByClassName return a live collection but querySelectorAll doesn’t? A: This is simply how each API was specified historically — getElementsByClassName/getElementsByTagName predate querySelectorAll and were designed to auto-update, while querySelectorAll was intentionally specified to return a static snapshot for more predictable behavior.

Q: Is it bad to use inline style properties in JavaScript? A: Not inherently, but for anything beyond simple dynamic values (like computed widths), toggling CSS classes keeps styling logic centralized in your stylesheets, which is usually easier to maintain.

Q: How do I check if an element exists before manipulating it? A: const el = document.querySelector(".maybe-exists"); if (el) { ... } — always guard against null before calling methods on the result.

Summary and Key Takeaways

DOM manipulation is where JavaScript logic meets what users actually see and interact with — getting comfortable with these APIs, and understanding the performance implications underneath them, is foundational to building fast, reliable web interfaces.

References

Exit mobile version