DOM, Events and Rendering
The DOM is a tree
The browser builds it for you
DOM — Document Object Model. Parsing your HTML (lesson 02) produced the
objects: one node per element, nested per nesting, with document as the root you
can address. The tree is live: change a node and the page updates — no re-parse, no
reloaded file.
// In a browser console, inspect the live tree of any page:
console.log(document.title); // the text, as a property
console.log(document.body.children); // direct element children of
console.log(document.body.children.length);
// On this page: an HTMLCollection whose length matches the
// top-level elements inside the body. Change nothing yet — just look.
Your JavaScript sits between the tree and the render pipeline: every change you make is materialized by style → layout → paint → composite.
Selecting elements
querySelector and friends
Two methods cover everything: querySelector (first match, or null)
and querySelectorAll (all matches, as a static NodeList). Both take any CSS
selector — the same syntax you style with:
// Browser context. Assume:
const menu = document.querySelector("#menu");
const items = menu.querySelectorAll("li");
console.log(items.length); // 2
console.log(items[0].textContent); // Home
items.forEach((li) => li.classList.add("menu-item")); // NodeList has forEach;
// array extras need [...items]
If you get this instead: TypeError: Cannot read properties of null —
the selector matched nothing. Log the selector's result alone before debugging anything else;
and remember a script in <head> runs before the elements below it exist
(lesson 03's defer).
Reading and writing content
textContent versus innerHTML — a security lesson
// User input must never become HTML. Watch the difference:
const name = '
';
const safe = document.createElement("span");
safe.textContent = name; // a TEXT node: the tags are shown as characters — harmless
// element.innerHTML = name; // DANGEROUS: the browser parses it as markup —
// the injected onerror RUNS. This is XSS, the #1 web vuln.
Rule: textContent for anything that came from a user or an API;
innerHTML only for markup you wrote yourself.
Styles and classes
const box = document.querySelector(".box");
box.classList.add("highlighted"); // classList: add / remove / toggle / contains
box.classList.toggle("active", true); // the second argument forces the state
box.style.color = "crimson"; // inline style: for the rare one-off
Creating and removing nodes
// Build detached, then attach once — the DOM is only touched at the end:
const list = document.querySelector("#todos");
const fragment = document.createDocumentFragment(); // an invisible container
for (const label of ["learn", "practice", "teach"]) {
const li = document.createElement("li");
li.textContent = label; // never innerHTML with dynamic data
fragment.append(li); // cheap: no layout work while detached
}
list.append(fragment); // ONE attachment, ONE layout pass
const first = list.firstElementChild;
first.remove(); // gone from the tree — and from the page
Events: reacting to the user
Every click travels: target, then bubbles up
An event fires at the element where it happened (the target), then bubbles up through its ancestors — each ancestor's listener can react. Listening on a stable parent is event delegation: one listener handles all current and future children.
// Delegation: ONE listener for a list that can grow forever.
document.querySelector("#todos").addEventListener("click", (event) => {
const item = event.target.closest("li"); // closest: the ancestor, if any
if (item) item.classList.toggle("done");
});
Contraexample: attaching a listener to each <li> at build
time. It works — until you add an item and forget its listener, or remove an item whose
listener keeps a closure alive (lesson 15's leak patterns).
The rendering cost: reflow versus repaint
The pipeline in the diagram runs after your script yields. Repaint (new colors) is
cheap; reflow (layout recalculated because sizes or positions changed) is not.
Reading layout properties (offsetWidth, getBoundingClientRect())
forces the browser to compute pending changes now — so alternating writes and reads
in a loop triggers a reflow per iteration:
// Slow: write, read, write, read — every read forces pending layout work
for (const el of items) {
el.style.width = el.offsetWidth + 10 + "px"; // read forces reflow, write invalidates it
}
// Fast: read everything first, then write everything
const widths = items.map((el) => el.offsetWidth); // all reads together
items.forEach((el, i) => { el.style.width = widths[i] + 10 + "px"; }); // all writes together
Accessibility: semantics beat attributes
A <button> gets keyboard focus, Enter/Space activation, and screen-reader
announcements for free. A <div onclick> gets none of it — you would have to
rebuild all three by hand. Reach for the semantic element first; add ARIA attributes only
when no native element fits.
Practice: rebuild the list
The task
Open demo runtime/24_dom_lab.html in a browser (Preview link on the Lab Examples
page). Type three languages, add them, remove one with the ✕. Then add a second input for a
description and render it as a nested <small> line — using delegation, so
removal keeps working without a single new listener.
The checklist
- You can predict
querySelectorreturning null — and the error that follows. - You use
textContentfor dynamic data, and can say why innerHTML is XSS. - You attach one delegated listener instead of one per node.
- You can explain reflow, and why read-then-write beats alternating.