JavaScript DOM Manipulation

5 minute read

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 =>
    ({ "&":"&amp;","<":"&lt;",">":"&gt;","\"":"&quot;","'":"&#39;" })[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 / querySelectorAll as your default selectors
  • ✅ Use textContent for any user-supplied text
  • ✅ Toggle classes; keep styles in CSS
  • ✅ Batch DOM inserts with DocumentFragment (or replaceChildren)
  • ✅ Use dataset for app data on elements
  • ✅ Use closest() for event delegation
  • ❌ Don’t set innerHTML from untrusted input
  • ❌ Don’t iterate over live HTMLCollections you’re mutating
  • ❌ Don’t run DOM queries before the page is parsed (use defer or DOMContentLoaded)

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.

← Previous: JS JSON Next: JS Events →

Updated: