JavaScript DOM Manipulation
The DOM (Document Object Model) is the browser’s live tree of every element on a page. JavaScript can read it, change it, add to it, and rip pieces out — that’s how every modern web UI works under the hood. This chapter covers the everyday API: selecting elements, changing their content and attributes, building new nodes, and removing them safely.
The DOM as a Tree
<!DOCTYPE html>
<html>
<head><title>Hi</title></head>
<body>
<h1 class="page-title">Hello</h1>
<ul id="list">
<li>A</li>
<li>B</li>
</ul>
</body>
</html>
document
└── html
├── head
│ └── title — "Hi"
└── body
├── h1.page-title — "Hello"
└── ul#list
├── li — "A"
└── li — "B"
Every tag is a node, every node is a JS object with properties (textContent, children, parentNode) and methods (appendChild, remove, addEventListener).
Selecting Elements
| Method | Returns | Live? |
|---|---|---|
document.getElementById(id) |
one element or null |
— |
document.querySelector(sel) |
first match or null |
— |
document.querySelectorAll(sel) |
NodeList of matches | ❌ static |
document.getElementsByClassName(c) |
HTMLCollection | ✅ live |
document.getElementsByTagName(t) |
HTMLCollection | ✅ live |
The modern default is querySelector / querySelectorAll — they take any CSS selector:
document.querySelector("#list"); // by id
document.querySelector(".page-title"); // by class
document.querySelector("ul li:first-child"); // structural
document.querySelectorAll("ul#list > li"); // all <li> children
document.querySelectorAll("input[type='email']"); // attribute
document.querySelectorAll("li:not(.done)"); // pseudo-class
You can also scope a query to a sub-tree:
const list = document.querySelector("#list");
list.querySelectorAll("li"); // only inside #list
Tip: querySelectorAll returns a static NodeList — it’s a snapshot. The legacy getElementsByClassName / getElementsByTagName return live collections that update when the DOM changes. Static is almost always what you want.
Reading and Writing Content
const h1 = document.querySelector(".page-title");
h1.textContent; // "Hello" — text only, safest
h1.innerHTML; // "Hello" — HTML string (also writes HTML)
h1.innerText; // "Hello" — visible text, respects CSS
// Writing
h1.textContent = "Greetings"; // ✅ safe
h1.innerHTML = "<em>Greetings</em>"; // ⚠ parses as HTML
h1.innerText = "Greetings\nfrom Mars"; // respects line breaks
Warning: Never set innerHTML from untrusted input — it’s a cross-site scripting (XSS) vulnerability. Use textContent for any user-supplied data. If you genuinely need to render HTML from a string, sanitise it first (e.g., DOMPurify).
Attributes and Properties
Two parallel worlds:
- Attributes are what’s in the HTML markup (
<input value="hi">) - Properties are on the JS object (
input.value)
They start in sync, then drift as the user interacts.
const input = document.querySelector("#email");
// Attributes — read/write the markup
input.getAttribute("name"); // "email"
input.setAttribute("placeholder", "you@");
input.removeAttribute("disabled");
input.hasAttribute("required");
// Properties — read/write the live state
input.value = "ada@example.com";
input.disabled = true;
input.checked = false; // for checkbox / radio
For boolean attributes (disabled, checked, hidden, required), set the property, not the attribute, to a boolean.
Classes and Styles
const el = document.querySelector(".card");
// classList — the modern API
el.classList.add("active", "highlighted");
el.classList.remove("active");
el.classList.toggle("expanded");
el.classList.toggle("dark", isDarkMode); // force on/off
el.classList.contains("highlighted"); // true
// Style — inline only, in camelCase
el.style.color = "red";
el.style.backgroundColor = "white";
el.style.fontSize = "1.25rem";
// Setting CSS variables
el.style.setProperty("--accent", "#3a9");
Prefer toggling classes over manipulating .style directly — keep styles in CSS.
Data Attributes
A standard, low-risk way to attach app data to elements:
<div class="card" data-user-id="42" data-role="admin">Ada</div>
const card = document.querySelector(".card");
card.dataset.userId; // "42" (kebab → camel)
card.dataset.role; // "admin"
card.dataset.status = "active"; // adds data-status="active"
dataset exposes all data-* attributes as a single object, camelCased.
Creating Elements
// Create
const li = document.createElement("li");
li.textContent = "C";
li.classList.add("item");
// Append into the tree
document.querySelector("#list").append(li);
// Order of insertion
parent.append(node, "more text", anotherNode); // end
parent.prepend(node); // start
sibling.before(node); // just before sibling
sibling.after(node); // just after
sibling.replaceWith(node); // replace sibling
// Old method (single child only, no strings)
parent.appendChild(node);
Modern methods (append, prepend, before, after, replaceWith) accept multiple nodes and strings; the legacy appendChild accepts one node only.
Building Larger Structures
Three approaches, ordered by quality:
// 1. Imperative — verbose but safe
const li = document.createElement("li");
const a = document.createElement("a");
a.href = "/users/1";
a.textContent = "Ada";
li.append(a);
// 2. Document fragment — batch updates, one reflow
const frag = document.createDocumentFragment();
for (const name of ["Ada", "Lin", "Rae"]) {
const li = document.createElement("li");
li.textContent = name;
frag.append(li);
}
document.querySelector("#list").append(frag);
// 3. innerHTML with template literals — concise, USE WITH ESCAPING
const items = ["Ada", "Lin", "Rae"];
list.innerHTML = items
.map(name => `<li>${escapeHtml(name)}</li>`)
.join("");
function escapeHtml(s) {
return s.replace(/[&<>"']/g, c =>
({ "&":"&","<":"<",">":">","\"":""","'":"'" })[c]);
}
| Approach | Speed | Safety | Best for |
|---|---|---|---|
createElement + append |
medium | ✅ | small builds, user data |
DocumentFragment |
fastest | ✅ | many nodes |
innerHTML |
medium | ⚠ XSS risk | template-style markup, no user input (or escaped) |
<template> elements are a fourth approach when you need to clone a fixed structure repeatedly.
Traversing
const li = document.querySelector("li");
li.parentNode; // parent element
li.parentElement; // same (excludes non-element parents)
li.children; // live HTMLCollection of element children
li.childNodes; // includes text nodes
li.firstElementChild;
li.lastElementChild;
li.nextElementSibling;
li.previousElementSibling;
li.closest(".list"); // walk up looking for matching selector
li.matches(".item"); // does this element match the selector?
closest is the workhorse for event delegation (next chapter).
Removing Nodes
const el = document.querySelector(".to-remove");
el.remove(); // modern — just go
el.parentNode.removeChild(el); // legacy form
To empty a container:
// Modern, clean
list.replaceChildren(); // removes all children
// Quick but slower with many children
list.innerHTML = "";
A Realistic Example — Render a Todo List
const todos = [
{ id: 1, text: "Buy milk", done: true },
{ id: 2, text: "Write docs", done: false },
{ id: 3, text: "Ship it", done: false },
];
function renderTodos(list, todos) {
list.replaceChildren(); // wipe existing
const frag = document.createDocumentFragment();
for (const t of todos) {
const li = document.createElement("li");
li.dataset.id = t.id;
li.classList.toggle("done", t.done);
const check = document.createElement("input");
check.type = "checkbox";
check.checked = t.done;
const label = document.createElement("span");
label.textContent = t.text; // ✅ safe — user text
li.append(check, " ", label);
frag.append(li);
}
list.append(frag);
}
renderTodos(document.querySelector("#todo-list"), todos);
Single reflow at the end, no XSS risk, classes do the styling.
Common Mistakes
// ❌ Selecting before the DOM has loaded
const el = document.querySelector("#root");
el.textContent = "hi"; // TypeError if script runs in <head>
// ✅ Run after DOMContentLoaded, or use defer on the <script>
document.addEventListener("DOMContentLoaded", () => { /* ... */ });
// ❌ Concatenating user input into innerHTML
list.innerHTML += `<li>${userName}</li>`; // XSS risk
// ✅
const li = document.createElement("li");
li.textContent = userName;
list.append(li);
// ❌ Forgetting that querySelectorAll is static
const items = document.querySelectorAll(".item");
list.append(makeItem());
items.length; // unchanged — re-query if you need fresh results
// ❌ getElementsByClassName mid-iteration — live collection mutates as you go
const lis = list.getElementsByClassName("item");
for (const li of lis) li.remove(); // index keeps shifting!
Best Practices Checklist
- ✅ Use
querySelector/querySelectorAllas your default selectors - ✅ Use
textContentfor any user-supplied text - ✅ Toggle classes; keep styles in CSS
- ✅ Batch DOM inserts with
DocumentFragment(orreplaceChildren) - ✅ Use
datasetfor app data on elements - ✅ Use
closest()for event delegation - ❌ Don’t set
innerHTMLfrom untrusted input - ❌ Don’t iterate over live HTMLCollections you’re mutating
- ❌ Don’t run DOM queries before the page is parsed (use
deferorDOMContentLoaded)
What’s Next?
You can find, build, and modify elements. The other half of interactive UIs is events — clicks, keystrokes, form submits, custom events. Next chapter covers the event model end to end.