Building an application
The DOM Without a Framework
The DOM is a tree of objects you can read and write directly. Query it, build nodes, keep user strings away from innerHTML, and stop forcing layout in a loop.
Open any page, hit the console, and type document.title = "hello". The tab changes. No build step, no framework, no state management library. The browser hands you an object graph that mirrors the page, and writing to it is what every UI library ends up doing underneath.
That is worth knowing for its own sake, because a framework is a set of decisions about this graph. It is also worth knowing when a framework is the wrong size for the job. A settings panel, a filter over a table, a form with three conditional fields: those are a few hundred lines of plain JavaScript, and adding a build pipeline and a component model to them costs more than it saves.
What follows is the part of the DOM API that covers nearly all of that work, plus two traps that produce bugs people blame on the browser.
The document is a tree, and every query is a walk
A page starts as markup and the parser turns it into nodes: elements, text, comments. The document object is the root of that tree, and everything you do starts by getting a reference to some node inside it.
const list = document.querySelector("#todos");
console.log("items:", list.children.length);
const second = list.querySelector('[data-id="2"]');
console.log("text:", second.textContent);
list.querySelector('[data-id="1"]').remove();
console.log("after remove:", [...list.children].map((li) => li.dataset.id));
const li = document.createElement("li");
li.dataset.id = "4";
li.textContent = "delete the old list";
li.classList.add("done");
list.append(li);
items: 3
text: write lesson
after remove: [ '2', '3' ]
querySelector takes CSS selector syntax and returns the first match in document order, or null. querySelectorAll returns every match. The selector syntax is the same one your stylesheet uses, which is the reason to learn selectors properly rather than reaching for ids everywhere.
The methods that matter once you have a node are closest and matches. matches answers whether a node satisfies a selector. closest walks up from the node, through its ancestors, and returns the first one that matches, or null. Together they are how you find out which list item a click landed in, from the click target itself.
const span = document.querySelector('[data-id="2"] span');
console.log("matches:", span.matches("span"), "| closest id:", span.closest("li").dataset.id);
matches: true | closest id: 2
Both work on a detached tree as well, which is convenient: you can build a structure, query inside it, and only then insert it.
const box = document.createElement("div");
box.innerHTML = '<p id="note">hello</p>';
const note = box.querySelector("#note");
console.log("querySelector on a detached tree works:", note.textContent);
querySelector on a detached tree works: hello
Creating and removing nodes, and which collections are live
You create an element with document.createElement, set its content and attributes, and attach it with append, prepend, before, after or replaceChildren. Removing is element.remove() or parent.removeChild(element). Modern APIs take several nodes at once, so list.replaceChildren(a, b, c) is a valid way to swap the contents of a container in one call.
The trap is in the collections. children and childNodes are live: they are views that update as the tree changes. querySelectorAll returns a static NodeList that does not. That difference is invisible until you remove nodes while iterating, and then it decides whether your loop visits half the elements or all of them.
const live = list.children;
const snapshot = list.querySelectorAll("li");
console.log("before: children", live.length, "qsAll", snapshot.length);
list.querySelector("li").remove();
console.log("after one removal: children", live.length, "qsAll", snapshot.length);
list.append(document.createElement("li"));
console.log("after append: children", live.length, "qsAll", snapshot.length);
for (const li of [...list.children]) li.remove();
console.log("cleared with a copy:", list.children.length);
before: children 4 qsAll 4
after one removal: children 3 qsAll 4
after append: children 4 qsAll 4
cleared with a copy: 0
The copy in that last loop is not stylistic. Iterating a live collection while removing from it skips elements, because each removal shifts the remaining ones down an index and the iterator has already moved past that slot. Spreading into an array freezes the set of nodes you intend to remove.
textContent is data, innerHTML is markup
This is the most consequential line in the API. textContent sets or returns the text of a node. Whatever string you assign becomes text, character for character, and the parser never sees it. innerHTML hands the string to the HTML parser and replaces the children with whatever element tree comes back.
const payload = '<img src=x onerror="steal(document.cookie)">';
const target = document.createElement("p");
target.textContent = payload;
console.log("textContent:", target.innerHTML, "| childNodes:", target.childNodes.length);
target.innerHTML = payload;
console.log("innerHTML: ", target.innerHTML, "| childNodes:", target.childNodes.length);
textContent: <img src=x onerror="steal(document.cookie)"> | childNodes: 1
innerHTML: <img src="x" onerror="steal(document.cookie)"> | childNodes: 1
Both produce one child node and neither throws, which is why this bug survives review. Read the difference in the two outputs. With textContent the node is a text node and its serialised form is escaped, so the angle brackets are literal characters. With innerHTML the node is an img element carrying a real onerror attribute whose value is JavaScript. The browser requested the image, the request failed because x is not a URL, and the handler ran.
I ran that in jsdom, which builds the element and the attribute but does not fetch images, so the handler never fired. The element and its attribute are the evidence. In a browser, the attribute executes.
The rule is about the string, not the method
innerHTML is fine when the string is yours: a template literal in your own source, a static fragment from the server you control. It is a vulnerability when any part of it came from a user, a URL parameter, a CMS field, an API response, or a third-party script. When it did, build the nodes and set textContent, or reach for <template> and clone it. Escaping by hand is the version of this that fails eventually, because there are more contexts to escape for than people remember.
If you must parse untrusted markup, Element.setHTML() with a trusted-types policy and a sanitizer is the modern path, and it is a different lesson. For an application built from data, textContent plus createElement is enough.
An attribute is not a property
Elements expose two views of the same markup. Attributes live in the HTML and are read with getAttribute. Properties live on the JavaScript object. For id and data-* values they usually agree, and the dataset object is a convenient window onto data-* attributes. For anything the user can change through the interface, they diverge.
const input = document.createElement("input");
input.setAttribute("value", "typed once");
input.value = "typed again";
console.log("getAttribute:", input.getAttribute("value"), "| property:", input.value);
const check = document.createElement("input");
check.type = "checkbox";
check.setAttribute("checked", "");
console.log("attribute:", check.getAttribute("checked"), "| property:", check.checked);
check.checked = false;
console.log("after property write, attribute still:", check.getAttribute("checked"));
getAttribute: typed once | property: typed again
attribute: | property: true
after property write, attribute still:
The attribute is the initial value from the markup. The property is the current value. Typing in a text field updates value and leaves the attribute alone. Ticking a checkbox updates checked. This matters as soon as you write a test that asserts the state of a control: assert the property, and read the attribute only when you mean the markup as it was parsed.
Classes have their own small API. classList.add, remove, toggle and contains operate on the class list without the string surgery that className requires, and toggle(name, force) sets or clears a class from a boolean, which is what you want when rendering from state.
One listener on the container beats one listener per row
Events bubble. A click on a span inside an li inside a ul dispatches on the span and then travels up through its ancestors. That means a listener on the container can handle clicks for every row inside it, including rows that did not exist when the listener was attached.
list.addEventListener("click", (event) => {
const item = event.target.closest("li[data-id]");
if (!item) return;
clicks += 1;
console.log("delegated handler saw id", item.dataset.id);
});
for (let id = 2; id <= 4; id += 1) {
const li = document.createElement("li");
li.dataset.id = String(id);
li.textContent = `item ${id}`;
list.append(li);
}
list.querySelector('[data-id="4"]').dispatchEvent(new MouseEvent("click", { bubbles: true }));
delegated handler saw id 4
clicks: 1
Delegation solves the problem that per-element listeners have with re-rendering. Attach a listener to a row, then replace the row, and the listener is gone with the node:
per-element clicks after re-render: 0
Nobody notices until a feature stops working after a redraw, which is why this class of bug appears late in a project. A listener on a container that outlives the rows has no such failure mode.
Removing listeners on teardown is the other half. The clean tool is AbortController: pass its signal in the options object when you attach, then call abort() to detach every listener registered with that signal.
const controller = new AbortController();
list.addEventListener("click", () => { clicks += 1; }, { signal: controller.signal });
list.querySelector("li").dispatchEvent(new MouseEvent("click", { bubbles: true }));
controller.abort();
list.querySelector("li").dispatchEvent(new MouseEvent("click", { bubbles: true }));
console.log("clicks before and after abort:", clicks);
clicks before and after abort: 1
One caveat that costs an afternoon when you test in Node: AbortController from Node's globals is a different class from the one on a jsdom window, and jsdom rejects it with a message about the parameter not being an AbortSignal. Use the controller from the same realm as the element. The same applies to MouseEvent and friends.
Reading a measurement after a write forces layout
The layout engine is lazy. Changing the DOM, a class, or a style marks the tree dirty and returns immediately. The next time something asks for a measurement, the browser has to recompute layout before it can answer, and that answer is synchronous: your code does not continue until the geometry is up to date.
Write, read, write, read in a loop and each read pays for a full layout pass on a tree that has changed since the last one. That is layout thrashing. It is not a slow API; it is a fast API being asked the same expensive question three hundred times.
function interleaved() {
board.replaceChildren();
for (const text of rows) {
const div = document.createElement("div");
div.textContent = text;
board.append(div);
div.style.padding = `${div.offsetHeight % 7}px`;
}
}
function batched() {
const fragment = document.createDocumentFragment();
for (const text of rows) {
const div = document.createElement("div");
div.textContent = text;
fragment.append(div);
}
board.replaceChildren(fragment);
const measured = board.offsetHeight;
for (const div of board.children) div.style.padding = `${measured % 7}px`;
}
jsdom implements the DOM but not layout, so the measurements in it are stubs. Counting the reads is still meaningful, and adding a simulated cost to each read shows what the browser is paying:
$ node demos/layout-thrash.mjs
interleaved read/write: 300 layout reads, 10.4 ms, 300 rows (jsdom does not lay out)
batched writes, one read: 1 layout reads, 11.6 ms, 300 rows (jsdom does not lay out)
$ REFLOW_MS=2 node demos/layout-thrash.mjs
interleaved read/write: 300 layout reads, 632.1 ms, 300 rows (simulating 2 ms per forced reflow)
batched writes, one read: 1 layout reads, 13.7 ms, 300 rows (simulating 2 ms per forced reflow)
The first run shows the real count: 300 forced layout reads against one. The second charges a realistic cost per layout pass and the difference moves from noise to two orders of magnitude. Both versions have the same shape: build everything detached, insert it once, then take every measurement you need in one place. When you genuinely need to read after each change, read into variables first, mutate after.
Build an editable list with plain DOM calls and no framework. The markup you start from:
<ul id="rows">
<li data-id="a"><span class="label">alpha</span> <button data-action="delete">remove</button></li>
<li data-id="b"><span class="label">beta</span> <button data-action="delete">remove</button></li>
</ul>
<button id="add">add</button>
Requirements, in order:
- One delegated
clicklistener on#rowshandles both delete buttons. Do not attach a listener to any row. - Deleting a row removes that row and nothing else, including when rows are added first.
- The add button appends a row whose label comes from a variable holding
'<b>new</b>'. The angle brackets must appear on screen as text. - Clicking a label toggles a
selectedclass on its row, usingclassList.togglewith a boolean.
Then answer, in writing: which of your collections would break if you removed rows while iterating it, and what does your delete handler use instead of that collection?
Show the solution and the reasoning
The shape that satisfies all four requirements is one listener and a data attribute that names the action. The listener decides what happened; the nodes carry the data.
const rows = document.querySelector("#rows");
const add = document.querySelector("#add");
let nextId = 0;
function makeRow(id, label) {
const li = document.createElement("li");
li.dataset.id = id;
const span = document.createElement("span");
span.className = "label";
span.textContent = label;
const button = document.createElement("button");
button.dataset.action = "delete";
button.textContent = "remove";
li.append(span, button);
return li;
}
rows.addEventListener("click", (event) => {
const button = event.target.closest('[data-action="delete"]');
if (button) {
button.closest("li").remove();
return;
}
const label = event.target.closest(".label");
if (!label) return;
const li = label.closest("li");
li.classList.toggle("selected", !li.classList.contains("selected"));
});
add.addEventListener("click", () => {
nextId += 1;
rows.append(makeRow(`n${nextId}`, "<b>new</b>"));
});
Three decisions in there are the point of the exercise.
span.textContent = label is what keeps the angle brackets on screen. Assigning the same string to span.innerHTML produces a bold element, and swapping the variable for a value from a user or an API turns the same line into a script injection. The requirement to show the literal text is a cheap way to force the safe path.
The handler never assumes the click landed on the element it cares about. A click on the button's text is a click on the button, but a click on a nested element is not, and a selector is the only reliable way to find out. That is why closest starts each branch rather than comparing event.target directly.
Nothing iterates rows.children. The delete branch removes one node found by traversal, so the live-collection problem cannot arise. If you wrote a clear-all button, the version that iterates correctly is for (const li of [...rows.children]) li.remove();, and the version that fails is the same loop without the spread: it removes every other row and leaves the rest.
On the toggle, note the two-argument form. classList.toggle("selected") reads the current state and flips it, which is fine here but wrong when the state lives elsewhere. Once you have a state object, you pass the boolean, and the DOM follows the state instead of deciding for itself. That is the subject of the next lesson.
Progress
Mark this lesson done and it stays marked in this browser.