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");
querySelectorreturns the first matching element, ornullif none match.querySelectorAllreturns a static NodeList of all matches — “static” meaning it does not automatically update if the DOM changes later.
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
| Method | Returns | Live or Static | Selector Type |
|---|---|---|---|
getElementById | Single element or null | N/A | ID only |
getElementsByClassName | HTMLCollection | Live | Class name |
getElementsByTagName | HTMLCollection | Live | Tag name |
querySelector | Single element or null | N/A | Any CSS selector |
querySelectorAll | NodeList | Static | Any 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)
| Property | Parses HTML? | Triggers Reflow for Hidden Elements | Security Risk |
|---|---|---|---|
textContent | No | No | Safe |
innerHTML | Yes | No | XSS risk if inserting untrusted content |
innerText | No | Yes (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>");
| Position | Meaning |
|---|---|
beforebegin | Before the element itself |
afterbegin | Just inside the element, before its first child |
beforeend | Just inside the element, after its last child |
afterend | After 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
- I use
querySelector/querySelectorAllas my default choice for flexibility, and cache references to elements I access repeatedly instead of re-querying the DOM. - I use
textContentfor plain text and reserveinnerHTMLfor trusted content only. - I batch DOM writes together and use
DocumentFragmentfor large-scale insertions. - I toggle CSS classes for styling changes rather than setting many individual inline styles.
Common Mistakes to Avoid
- Using
innerHTMLwith unsanitized user input — a direct XSS vulnerability. - Querying the DOM repeatedly inside loops instead of caching the reference once.
- Causing layout thrashing by alternating reads and writes of layout-affecting properties in a loop.
- Forgetting that
querySelectorAllreturns a static snapshot, and expecting it to reflect later DOM changes.
Debugging Tips
- I use the Elements panel in DevTools to inspect the live DOM tree and confirm my selectors actually match what I expect.
- I use
console.log(element)directly (not just its properties) to inspect the full element in the console. - For “why isn’t my style applying” issues, I check
window.getComputedStyle()to see the actual final computed value, accounting for CSS specificity and inheritance.
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
querySelector/querySelectorAllare the most flexible, commonly used selection methods; legacy methods likegetElementByIdstill have their place for simple, fast lookups.- Understand the difference between live collections (
HTMLCollection) and static snapshots (NodeListfromquerySelectorAll). - Use
textContentoverinnerHTMLunless you specifically need to render markup, and always sanitize untrusted input. - Batch DOM reads and writes separately to avoid layout thrashing, and use
DocumentFragmentfor large insertions. - Prefer
classListfor styling changes to keep JavaScript and CSS concerns cleanly separated.
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.