JavaScript in the browser Core
The DOM
Read and change a web page from JavaScript: find, create, update and remove elements safely, avoid innerHTML XSS, and build a task list UI rendered from data.
BY THE END OF THIS LESSON YOU CAN
- Explain how a browser turns HTML into a tree of nodes, and tell nodes from elements
- Find elements with selectors and move around the tree without tripping over text nodes
- Create, update, move and remove elements, and choose between attributes and properties
- Put user text on a page without opening an XSS hole
- Render a list from data with a template and a DocumentFragment, and check the result by reading the page back
A page that has to change
Ada runs a small cleaning business and keeps her jobs in a task app. The server sends the page as HTML: a heading, a list of three tasks and a line that says how many there are. Then Ada types a new task and presses Enter. The server's HTML cannot change by itself: it was sent once, as text, and the browser has already read it. Yet the new task has to appear, straight away, without reloading the page.
Something between the HTML text and the pixels on the screen must be changeable from JavaScript. That something is the DOM, the Document Object Model: a tree of JavaScript objects that the browser builds from the HTML, one object for every element and every piece of text. The browser draws the screen from this tree, not from the HTML text. Change the tree, and the screen follows.
Here is Ada's page. Every example in this lesson runs against a real page like it, in a preview frame that appears when you press Run in browser:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Ada's jobs</title>
<base href="https://tasks.example/">
</head>
<body>
<main id="app">
<h1>Today</h1>
<!-- the server rendered these three tasks -->
<ul id="tasks" class="task-list">
<li class="task" data-id="1">Buy detergent</li>
<li class="task done" data-id="2">Pay the electricity bill</li>
<li class="task" data-id="3">Clean the Okafor flat</li>
</ul>
<p class="summary">3 tasks</p>
<a id="history" href="jobs/2026?view=list">All jobs</a>
</main>
</body>
</html>
This program adds a fourth task to the tree:
const list = document.querySelector("#tasks");
const item = document.createElement("li");
item.textContent = "Collect keys from Mrs Bello";
list.append(item);
console.log("items in the list:", list.children.length);
console.log("summary says:", document.querySelector(".summary").textContent);
items in the list: 4 summary says: 3 tasks
The new task shows up in the preview. But look at the second line: the summary still says "3 tasks". The page now contradicts itself. Nothing in the DOM knows that the summary is about the list; each element is just an object in a tree. Keeping every part of the page in step with your data is the real work of browser code, and this lesson ends with a design that makes it easy: keep the tasks in an array, and render the page from that array every time it changes.
On the way you will learn how the tree is built, how to find things in it, how to change it safely (one wrong property turns a task title into an attack), and how to change a lot of it at once without making the browser do the same work many times.
The document is a tree of nodes
When the browser receives HTML, it parses it: it reads the text from start to end and builds the tree. Every element becomes an object; the text between tags becomes objects too. The global document is the root of the tree. The top of Ada's page looks like this:
document
└── html
├── head
│ ├── meta
│ ├── title ── "Ada's jobs"
│ └── base
└── body
└── main#app
├── "⏎ " (text: just whitespace)
├── h1 ── "Today"
├── "⏎ "
├── <!-- comment -->
├── "⏎ "
├── ul#tasks
│ ├── "⏎ "
│ ├── li ── "Buy detergent"
│ └── …
└── …
Every object in the tree is a node. There are several kinds, and each has a number in nodeType:
| Kind | nodeType | Example |
|---|---|---|
| Element node | 1 | <li>, <ul>: anything with a tag |
| Text node | 3 | The words inside an element, and the line breaks and spaces between tags |
| Comment node | 8 | <!-- … --> |
| Document node | 9 | document itself |
An element is a node that came from a tag. The difference matters because the whitespace you use to indent HTML becomes text nodes. This walks the tree recursively (the technique from Recursion) and prints every node under #app:
function describe(node) {
if (node.nodeType === Node.ELEMENT_NODE) return `<${node.tagName.toLowerCase()}>`;
if (node.nodeType === Node.TEXT_NODE) {
return node.textContent.trim() === "" ? "(whitespace)" : JSON.stringify(node.textContent);
}
if (node.nodeType === Node.COMMENT_NODE) return "<!-- comment -->";
return node.nodeName;
}
function walk(node, depth = 0) {
console.log(" ".repeat(depth) + describe(node));
for (const child of node.childNodes) walk(child, depth + 1);
}
walk(document.querySelector("#tasks"));
const app = document.querySelector("#app");
console.log("childNodes of #app:", app.childNodes.length, "children of #app:", app.children.length);
<ul>
(whitespace)
<li>
"Buy detergent"
(whitespace)
<li>
"Pay the electricity bill"
(whitespace)
<li>
"Clean the Okafor flat"
(whitespace)
childNodes of #app: 11 children of #app: 4childNodes gives every child node, whitespace included; children gives only the element children. #app has 4 elements (h1, ul, p, a) but 11 nodes. Most of the time you want elements, so most of the time you want the properties with Element in their name, which you will meet in the section on moving around the tree.
Each node is an object made from a class, and the classes form a prototype chain, as you saw in Prototypes. An <li> is an HTMLLIElement, which is an HTMLElement, which is an Element, which is a Node, which is an EventTarget. That is why every element has textContent (from Node), querySelector (from Element) and addEventListener (from EventTarget):
const li = document.querySelector("li");
const chain = [];
for (let proto = Object.getPrototypeOf(li); proto; proto = Object.getPrototypeOf(proto)) {
chain.push(proto.constructor.name);
}
console.log(chain.join(" -> "));
console.log(li instanceof HTMLElement, li instanceof Node, li instanceof EventTarget);
HTMLLIElement -> HTMLElement -> Element -> Node -> EventTarget -> Object true true true
NOTE
The DOM is not part of JavaScript. It is a Web API: an interface the browser gives to JavaScript, specified by the WHATWG DOM and HTML standards. Node.js runs the same JavaScript language without adocument, which is why these examples only run in the browser. Browser APIs looks at the other Web APIs.Finding elements
Before you can change an element you need a reference to it. The modern tools take a CSS selector, the same pattern language you use in stylesheets:
document.querySelector(selector)returns the first matching element, ornull.document.querySelectorAll(selector)returns all matches as aNodeList, which you can loop over withfor...ofor turn into an array with[...list].document.getElementById(id)is the older, direct way to find one element by itsid.
Both query methods also exist on every element, and then they search only inside that element:
const titles = (elements) => [...elements].map((el) => el.textContent);
console.log(document.querySelector("li").textContent);
console.log(titles(document.querySelectorAll("li.task:not(.done)")));
console.log(titles(document.querySelectorAll('[data-id="3"]')));
console.log(document.getElementById("tasks") === document.querySelector("#tasks"));
const list = document.querySelector("#tasks");
console.log(list.querySelectorAll("li").length, document.querySelectorAll("*").length);
console.log(document.querySelector(".invoice"));
Buy detergent [ 'Buy detergent', 'Clean the Okafor flat' ] [ 'Clean the Okafor flat' ] true 3 14 null
The last line is the one that bites. querySelector returns null when nothing matches, and the very next line, .textContent on null, throws a TypeError. A typo in a selector, or an element that another script removed, fails far away from its cause. For elements your page cannot work without, fail loudly and early with a helper that names the selector:
function mustFind(selector, root = document) {
const el = root.querySelector(selector);
if (el === null) throw new Error(`Page is missing ${selector}`);
return el;
}
console.log(mustFind("#tasks").id);
try {
mustFind("#task").textContent;
} catch (error) {
console.log(error.message);
}
try {
document.querySelector("#task").textContent;
} catch (error) {
console.log(error.name + ": " + error.message);
}
tasks Page is missing #task TypeError: Cannot read properties of null (reading 'textContent')
Two more selector methods help when you already have an element: el.matches(selector) asks "does this element match?", and el.closest(selector) walks up from the element (starting with itself) and returns the first ancestor that matches. closest is the key to event delegation in the next lesson.
const bill = document.querySelector('[data-id="2"]');
console.log(bill.matches(".done"), bill.matches(".task:not(.done)"));
console.log(bill.closest("ul").id, bill.closest("main").id);
console.log(bill.closest("form"));
true false tasks app null
Live and static collections
querySelectorAll returns a static list: a snapshot of the matches at the moment you called it. The older getElementsByClassName and getElementsByTagName return a live HTMLCollection that updates itself whenever the document changes. Watch both after a fourth task is added:
const live = document.getElementsByClassName("task");
const snapshot = document.querySelectorAll(".task");
const item = document.createElement("li");
item.className = "task";
item.textContent = "Order new mop heads";
document.querySelector("#tasks").append(item);
console.log("live:", live.length, "snapshot:", snapshot.length);
live: 4 snapshot: 3
A live collection sounds convenient, and it causes one of the classic DOM bugs, which you will see in When DOM code fails. Prefer querySelectorAll: a snapshot never changes under your feet.
Moving around the tree
From any node you can reach its relatives. There are two sets of properties, and the difference is the whitespace you saw earlier:
| Any node (includes text) | Elements only |
|---|---|
parentNode | parentElement |
childNodes | children |
firstChild, lastChild | firstElementChild, lastElementChild |
nextSibling, previousSibling | nextElementSibling, previousElementSibling |
const list = document.querySelector("#tasks");
const first = list.firstElementChild;
console.log(JSON.stringify(list.firstChild.textContent));
console.log(first.textContent);
console.log(first.nextElementSibling.textContent);
console.log(JSON.stringify(first.nextSibling.textContent));
console.log(list.lastElementChild.dataset.id, list.parentElement.id);
console.log(list.previousElementSibling.nodeName, list.nextElementSibling.className);
"\n " Buy detergent Pay the electricity bill "\n " 3 app H1 summary
firstChild is the line break and indentation after <ul>, not the first task. If code that worked yesterday breaks after someone reformats the HTML, look for a firstChild or nextSibling that should have been the Element version. Better still, don't depend on the exact shape of the tree: find elements by selector, with closest and querySelector, so that adding a wrapper <div> later breaks nothing.
Reading and changing content
Three properties read and write what is inside an element:
textContent: all the text inside, as plain text. Setting it replaces every child with a single text node. Whatever string you give it is shown as text, even if it looks like HTML.innerHTML: the inside as an HTML string. Setting it makes the browser parse your string as HTML and build new elements from it.innerText: liketextContent, but it follows the CSS: it leaves out hidden text and adds line breaks where the layout has them. It has to ask the browser how the page is laid out, which makes it slower. UsetextContentunless you really want "the text as the user sees it".
const summary = document.querySelector(".summary");
summary.textContent = "4 tasks <b>today</b>";
console.log(summary.textContent);
console.log(summary.innerHTML);
console.log(summary.children.length);
summary.innerHTML = "4 tasks <b>today</b>";
console.log(summary.textContent);
console.log(summary.children.length, summary.firstElementChild.tagName);
4 tasks <b>today</b> 4 tasks <b>today</b> 0 4 tasks today 1 B
With textContent, the angle brackets are just characters: innerHTML shows them escaped as < and >, and no <b> element exists. With innerHTML, the browser built a real <b> element. That difference is harmless for a string you wrote. It is dangerous for a string a user wrote.
innerHTML and XSS
Task titles come from users. Suppose someone saves a task with this title. It looks like a broken image tag, and it contains a small program in its onerror attribute:
const title = `Fix sink <img src="data:," onerror="window.stolen = 'attacker code ran'">`;
const list = document.querySelector("#tasks");
list.innerHTML += `<li class="task">${title}</li>`;
const img = list.querySelector("img");
await new Promise((resolve) => img.addEventListener("error", resolve));
console.log("images in the list:", list.querySelectorAll("img").length);
console.log(window.stolen);
images in the list: 1 attacker code ran
The browser parsed the title as HTML, created a real <img>, failed to load the empty image, and ran the attacker's onerror code, in your page, with your page's permissions. This is cross-site scripting (XSS): an attacker gets their script to run on your site. Here it only set a variable. A real one would read the user's data, send requests as the logged-in user, or replace the login form with one that sends the password elsewhere. Every other user who opens the task list runs it too.
The fix costs nothing: put user text in with textContent, on an element you created yourself:
const title = `Fix sink <img src="data:," onerror="window.stolen = 'attacker code ran'">`;
const li = document.createElement("li");
li.className = "task";
li.textContent = title;
document.querySelector("#tasks").append(li);
console.log("images in the list:", document.querySelectorAll("#tasks img").length);
console.log(li.textContent === title);
console.log(window.stolen);
images in the list: 0 true undefined
The title is stored exactly as the user typed it, shown exactly as they typed it, and nothing runs. Remember the rule as: data goes in with textContent (or value, or setAttribute), never with innerHTML. innerHTML is fine only for HTML you wrote yourself, with no user data in it.
WATCH OUT
A<script> tag inserted with innerHTML does not run, which makes people think innerHTML is safe. It is not: event-handler attributes such as onerror and onload run, as you just saw. The same goes for insertAdjacentHTML, outerHTML and document.write. The web security lesson covers XSS in full, including Content Security Policy, a response header that tells the browser to refuse inline scripts like this one.Sometimes you really do need to build HTML as a string, for example in a server-side template. Then every piece of data must be escaped: the five characters that mean something in HTML are replaced by their entity names:
function escapeHtml(text) {
return String(text)
.replaceAll("&", "&")
.replaceAll("<", "<")
.replaceAll(">", ">")
.replaceAll('"', """)
.replaceAll("'", "'");
}
const title = `Fix sink <img src="data:," onerror="alert(1)">`;
const li = document.querySelector("li");
li.innerHTML = `<span class="title">${escapeHtml(title)}</span>`;
console.log(li.querySelector(".title").textContent === title);
console.log(li.querySelectorAll("img").length);
console.log(escapeHtml(`Bello & Sons' "deluxe" <clean>`));
true 0 Bello & Sons' "deluxe" <clean>
The & must be replaced first; otherwise the & in < would be escaped a second time. That kind of ordering detail is why hand-written escaping is a last resort, and textContent is the default.
Creating, inserting, moving and removing
document.createElement(tag) makes a new element that is not in the page yet. It appears only when you insert it. The modern insertion methods accept elements and strings (strings become text nodes, never HTML), and several at once:
| Method | Where the new nodes go |
|---|---|
parent.append(…nodes) | at the end, inside parent |
parent.prepend(…nodes) | at the start, inside parent |
el.before(…nodes) / el.after(…nodes) | just before or after el, as its siblings |
el.replaceWith(…nodes) | in place of el |
parent.replaceChildren(…nodes) | replaces all of parent's children |
el.remove() | takes el out of the page |
const list = document.querySelector("#tasks");
const titles = () => [...list.children].map((li) => li.textContent).join(" | ");
function taskItem(title) {
const li = document.createElement("li");
li.className = "task";
li.textContent = title;
return li;
}
list.prepend(taskItem("Urgent: return the Adeyemi deposit"));
list.querySelector('[data-id="2"]').after(taskItem("File the receipt"));
list.querySelector('[data-id="3"]').remove();
console.log(titles());
const first = list.firstElementChild;
list.append(first);
console.log(titles());
console.log(list.children.length);
Urgent: return the Adeyemi deposit | Buy detergent | Pay the electricity bill | File the receipt Buy detergent | Pay the electricity bill | File the receipt | Urgent: return the Adeyemi deposit 4
The last three lines show something surprising: append on an element that is already in the page moves it. A node can only be in one place, so inserting it somewhere removes it from where it was. To have a copy, clone it: el.cloneNode(true) copies the element and everything inside it (false copies only the element itself).
A removed element is not destroyed. It is an ordinary object: as long as a variable refers to it, you can inspect it and insert it again. It is garbage-collected only once nothing refers to it any more.
Attributes and properties
In the HTML, class="task done" and data-id="2" are attributes: name and text value pairs written in the tag. On the element object, there are properties: normal JavaScript properties such as li.className or input.value. For many attributes the browser keeps a matching property in sync, which makes them look like the same thing. They are not, and three differences cause real bugs.
1. Properties have types; attributes are always strings
getAttribute returns the text from the tag, or null if it is missing. Properties are converted into something useful: href becomes a full address, resolved against the page's base URL, and boolean attributes such as hidden, disabled and checked become true or false:
const link = document.querySelector("#history");
console.log(link.getAttribute("href"));
console.log(link.href);
const summary = document.querySelector(".summary");
console.log(summary.hidden, summary.getAttribute("hidden"));
summary.hidden = true;
console.log(summary.hidden, JSON.stringify(summary.getAttribute("hidden")));
summary.setAttribute("hidden", "false");
console.log(summary.hidden);
jobs/2026?view=list https://tasks.example/jobs/2026?view=list false null true "" true
The last line is the trap: a boolean attribute is on when it is present, whatever its value. hidden="false" hides the element. To switch it off, use the property (summary.hidden = false) or removeAttribute("hidden").
2. Form values: the attribute is the starting value
For an <input>, the value attribute is the default value from the HTML. The value property is what is in the box right now, including whatever the user typed. Typing changes the property, never the attribute:
<!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8"><title>New task</title></head>
<body>
<form id="new-task">
<label>Title <input name="title" value="Buy detergent"></label>
<label><input type="checkbox" name="urgent"> Urgent</label>
</form>
</body>
</html>
const input = document.querySelector('input[name="title"]');
input.value = "Buy detergent and gloves";
console.log("property:", input.value);
console.log("attribute:", input.getAttribute("value"));
console.log("defaultValue:", input.defaultValue);
const urgent = document.querySelector('input[name="urgent"]');
urgent.checked = true;
console.log(urgent.checked, urgent.hasAttribute("checked"));
document.querySelector("#new-task").reset();
console.log("after reset:", input.value, urgent.checked);
property: Buy detergent and gloves attribute: Buy detergent defaultValue: Buy detergent true false after reset: Buy detergent false
Read what the user entered from input.value and checkbox.checked. Reading getAttribute("value") is a common bug: it always returns what the HTML said, so a form handler that uses it ignores everything the user typed.
3. class and data-* have their own tools
class is a reserved word in JavaScript, so the property is className (the whole string). Better is classList, which adds, removes and toggles one class without touching the others. Custom data-* attributes, the standard place to keep small bits of data on an element such as a task's id, appear on dataset, with the name after data- turned into camelCase. Their values are always strings:
const li = document.querySelector('[data-id="1"]');
li.classList.add("urgent");
li.classList.toggle("done");
console.log(li.className, li.classList.contains("done"));
console.log(li.dataset.id, typeof li.dataset.id);
li.dataset.dueDate = "2026-10-01";
console.log(li.getAttribute("data-due-date"));
console.log(li.outerHTML);
task urgent done true 1 string 2026-10-01 <li class="task urgent done" data-id="1" data-due-date="2026-10-01">Buy detergent</li>
dataset.id is the string "1", not the number 1. When you look a task up by the id stored on its element, convert it first (Number(li.dataset.id)), or tasks.find((t) => t.id === li.dataset.id) will never find anything. outerHTML is the element itself as HTML, handy for seeing what your code really produced.
Style works the same way: el.style.color = "red" sets an inline style, one property at a time. Prefer toggling a class and keeping the looks in CSS; the stylesheet stays the one place that decides how a "done" task looks, and your code only says that it is done.
Changing many things at once
Every change to the page's tree can make the browser recalculate styles, work out where everything goes (layout) and draw the result again (paint). Browsers are clever about waiting until your code has finished before they draw, but you can still make them do far more work than needed. You can see how many separate changes a piece of code makes with a MutationObserver, a Web API that records changes to a part of the tree. Here, three tasks are added one at a time, then three more in one go with a DocumentFragment: a lightweight container node that is not part of the page:
const list = document.querySelector("#tasks");
const observer = new MutationObserver(() => {});
observer.observe(list, { childList: true });
function taskItem(title) {
const li = document.createElement("li");
li.className = "task";
li.textContent = title;
return li;
}
for (const title of ["Wash windows", "Mop kitchen", "Dust shelves"]) {
list.append(taskItem(title));
}
console.log("one at a time:", observer.takeRecords().length, "changes");
const fragment = document.createDocumentFragment();
for (const title of ["Iron curtains", "Empty bins", "Water plants"]) {
fragment.append(taskItem(title));
}
console.log("fragment holds", fragment.childNodes.length, "items");
list.append(fragment);
console.log("with a fragment:", observer.takeRecords().length, "change");
console.log("fragment now holds", fragment.childNodes.length, "items; list has", list.children.length);
observer.disconnect();
one at a time: 3 changes fragment holds 3 items with a fragment: 1 change fragment now holds 0 items; list has 9
When you insert a fragment, its children move into the page in a single operation and the fragment is left empty. Build a list of any size off the page, in a fragment, then insert it once. parent.append(a, b, c) and replaceChildren(…) with several nodes do the same thing internally.
A second habit matters even more: don't mix reading layout with writing to the page inside a loop. Properties such as offsetHeight, getBoundingClientRect() and innerText need an up-to-date layout. If you changed the page just before, the browser has to calculate the layout right then, and doing that once per loop turn (called layout thrashing) can make a page stutter. Read everything first, then write everything.
Templates
A task row is not one element but several: a checkbox, the title, a delete button. Building that with createElement line by line is long and hides what the row looks like. A <template> element holds HTML that the browser parses but does not show, does not run, and does not include in searches of the page. You clone its content each time you need a row, then fill in the data with textContent:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Ada's jobs</title>
</head>
<body>
<main id="app">
<h1>Today</h1>
<p class="error" role="alert" hidden></p>
<ul id="tasks" class="task-list"></ul>
<p class="summary" aria-live="polite"></p>
</main>
<template id="task-row">
<li class="task">
<label><input type="checkbox" class="toggle"> <span class="title"></span></label>
<button type="button" class="delete">Delete</button>
</li>
</template>
</body>
</html>
body { font-family: system-ui, sans-serif; margin: 1rem; }
.task-list { list-style: none; padding: 0; }
.task { display: flex; justify-content: space-between; gap: 0.5rem; padding: 0.4rem 0; border-bottom: 1px solid #ddd; }
.task.done .title { text-decoration: line-through; color: #666; }
.error { color: #b00020; }
const template = document.querySelector("#task-row");
console.log(template.content.constructor.name);
console.log("rows in the page:", document.querySelectorAll(".task").length);
const row = template.content.firstElementChild.cloneNode(true);
row.querySelector(".title").textContent = "Buy detergent";
row.classList.add("done");
row.querySelector(".toggle").checked = true;
document.querySelector("#tasks").append(row);
console.log("rows in the page:", document.querySelectorAll(".task").length);
console.log("rows in the template:", template.content.querySelectorAll(".task").length);
console.log(getComputedStyle(row.querySelector(".title")).textDecorationLine);
DocumentFragment rows in the page: 0 rows in the page: 1 rows in the template: 1 line-through
template.content is a DocumentFragment that lives outside the page. cloneNode(true) gives you a fresh copy of the row every time, and the template itself stays untouched for the next one. The last line asks the browser for the computed style, the final value after all CSS rules are applied: the .task.done .title rule from app.css crossed the title out. The code only added a class.
When DOM code fails
Most DOM bugs fall into a handful of patterns. Each of these runs for real in the preview.
The script ran before the element existed
The browser builds the tree from top to bottom and runs a plain <script> as soon as it reaches it. A script in the <head> runs before the <body> has been parsed, so its querySelector finds nothing. This page records what its head script saw, straight away and after the DOMContentLoaded event, which fires once the whole HTML has been parsed:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Early script</title>
<script>
window.foundEarly = document.querySelector("#tasks");
document.addEventListener("DOMContentLoaded", () => {
window.foundLater = document.querySelector("#tasks");
});
</script>
</head>
<body>
<ul id="tasks"><li>Buy detergent</li></ul>
</body>
</html>
console.log("found while parsing the head:", window.foundEarly);
console.log("found after DOMContentLoaded:", window.foundLater.id);
found while parsing the head: null found after DOMContentLoaded: tasks
The usual fix is to load scripts with <script src="app.js" defer> (or type="module", which defers automatically): the file downloads in parallel, and runs after the HTML has been parsed, in order. Putting the script at the end of <body> also works.
Looping over a live collection while changing it
The goal: clear the done class from every task. The collection is live, so each removal shrinks it while the loop is still counting:
<!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8"><title>Live bug</title></head>
<body>
<ul id="tasks">
<li class="task done">Buy detergent</li>
<li class="task done">Pay the electricity bill</li>
<li class="task done">Clean the Okafor flat</li>
<li class="task done">Order mop heads</li>
</ul>
</body>
</html>
const done = document.getElementsByClassName("done");
for (let i = 0; i < done.length; i++) {
done[i].classList.remove("done");
}
console.log("still done:", [...document.querySelectorAll(".done")].map((li) => li.textContent));
for (const li of document.querySelectorAll(".done")) li.classList.remove("done");
console.log("after the static loop:", document.querySelectorAll(".done").length);
still done: [ 'Pay the electricity bill', 'Order mop heads' ] after the static loop: 0
Removing the class from item 0 drops it out of the collection, so the item that was at index 1 moves to index 0, and the loop, now at i = 1, skips it. Every second item survives. The static NodeList from querySelectorAll does not change while you loop, so the second loop gets them all.
innerHTML += throws the old elements away
el.innerHTML += more reads the whole inside as a string, adds to it, and parses the result into brand-new elements. Everything that lived only on the old elements is lost: what the user typed, which checkbox was ticked, focus, and event listeners. Any variable that pointed to an old element now points to one that is no longer in the page:
const input = document.querySelector('input[name="title"]');
input.value = "Buy detergent and gloves";
document.body.innerHTML += "<p>Draft saved</p>";
console.log("old input still in the page?", input.isConnected);
console.log("value in the box now:", document.querySelector('input[name="title"]').value);
old input still in the page? false value in the box now: Buy detergent
The box shows the default value from the HTML again: the user's typing is gone, silently, and input now points to an element nobody can see. Use append with a created element, or insertAdjacentHTML("beforeend", html) for your own HTML, both of which leave existing elements alone.
Before you build: rendering from data
You now have every tool for Ada's task list. The design question is where the truth lives. One option is to treat the DOM as the data: to add a task, append an <li>; to count open tasks, count elements without a done class. That is how the example at the top of the lesson ended up with a summary that lied. The other option is to keep the tasks in an ordinary array, the state, and write one render() function that makes the page match the state. Every action changes the state and calls render(). The DOM becomes an output, never an input.
REASON IT OUT
What can go wrong in a task list?
Before writing the code, think through these questions:
- A user can type anything as a title. Which inputs should be refused, and which should be stored exactly as typed?
- A title contains
<,&or a whole<img onerror=…>tag. What must be true of the code that puts it on the page? - Two tasks can have the same title. How does a click on a row know which task it belongs to?
render()runs after every change. What happens to the previous rows, and can anything be left behind or shown twice?- How would a test know the page is right, without looking at the screen?
Show the reasoning
- Refuse what cannot be a task: an empty title, or one that is only spaces (
trim()first), and absurdly long ones (pick a limit, such as 120 characters). Store as typed everything else, including quotes and angle brackets: they are legitimate text. - Titles must only ever reach the page through
textContent(or attributes set withsetAttribute), never throughinnerHTML. Then any title is shown as text and nothing in it can run. - Give every task an id from a counter, store it on the row as
data-id, and look tasks up by id. Titles are not unique; ids are. Remember thatdataset.idcomes back as a string. replaceChildren(fragment)removes every old row and inserts the new ones in one operation, so nothing can be duplicated or left over. For a list of a few hundred rows, rebuilding it is fast enough.- A test can call the actions, then read the page back: the titles, the checkbox states and the summary text, the same things a user sees, and compare them with what it expects.
Build: a task list UI
The page is the task-ui project from the templates section: an empty <ul>, a summary line, a hidden error box and the row template. The program keeps the tasks in state, and every action ends with render(). At the bottom it runs a short scenario, including a hostile title and an empty one, and prints the page the way a user would read it:
const state = {
tasks: [
{ id: 1, title: "Buy detergent", done: false },
{ id: 2, title: "Pay the electricity bill", done: true },
],
nextId: 3,
};
const list = document.querySelector("#tasks");
const summary = document.querySelector(".summary");
const errorBox = document.querySelector(".error");
const rowTemplate = document.querySelector("#task-row");
function renderTask(task) {
const li = rowTemplate.content.firstElementChild.cloneNode(true);
li.dataset.id = String(task.id);
li.classList.toggle("done", task.done);
li.querySelector(".toggle").checked = task.done;
li.querySelector(".title").textContent = task.title;
li.querySelector(".delete").setAttribute("aria-label", `Delete ${task.title}`);
return li;
}
function render() {
const fragment = document.createDocumentFragment();
for (const task of state.tasks) fragment.append(renderTask(task));
list.replaceChildren(fragment);
const open = state.tasks.filter((t) => !t.done).length;
summary.textContent = `${open} of ${state.tasks.length} tasks left`;
}
function showError(message) {
errorBox.textContent = message;
errorBox.hidden = message === "";
}
function addTask(rawTitle) {
const title = String(rawTitle).trim();
if (title === "") return showError("A task needs a title.");
if (title.length > 120) return showError("Keep the title under 120 characters.");
showError("");
state.tasks.push({ id: state.nextId++, title, done: false });
render();
}
function toggleTask(id) {
const task = state.tasks.find((t) => t.id === id);
if (!task) return;
task.done = !task.done;
render();
}
function removeTask(id) {
state.tasks = state.tasks.filter((t) => t.id !== id);
render();
}
function readPage() {
const rows = [...list.children].map((li) => {
const box = li.querySelector(".toggle").checked ? "[x]" : "[ ]";
return `${box} #${li.dataset.id} ${li.querySelector(".title").textContent}`;
});
const error = errorBox.hidden ? "(no error)" : `error: ${errorBox.textContent}`;
return [...rows, summary.textContent, error].join("\n");
}
render();
addTask(" Clean the Okafor flat ");
addTask(`Fix sink <img src="data:," onerror="window.stolen = 1">`);
toggleTask(1);
removeTask(2);
addTask(" ");
console.log(readPage());
console.log("images:", list.querySelectorAll("img").length, "stolen:", window.stolen);
[x] #1 Buy detergent [ ] #3 Clean the Okafor flat [ ] #4 Fix sink <img src="data:," onerror="window.stolen = 1"> 2 of 3 tasks left error: A task needs a title. images: 0 stolen: undefined
Walk through what happened:
- The first title was trimmed before it was stored. The hostile title was stored and shown exactly as typed, as text: no image, no script.
- Toggling task 1 and removing task 2 only changed
state;render()made the page match, summary included. The summary can no longer disagree with the list, because both are drawn from the same array in the same function. - The blank title was refused with a message in the error box. The box has
role="alert"so screen readers announce it, and the summary hasaria-live="polite"so changes to it are read out too. - Each delete button got an
aria-labelsuch as "Delete Buy detergent": a screen reader user hears which task the button deletes, not five buttons all called "Delete".
The buttons and checkboxes do nothing yet when clicked. Connecting them to toggleTask and removeTask takes one event listener on the list, which is the first thing Events builds.
Testing DOM code
readPage() above is already a test tool: it turns the page into text you can compare. A test for a DOM function sets up the page, calls the function, and checks what a user would see, not how the code did it. It looks at text, checkbox states and attributes, not at private variables. Here renderTask is tested on its own, including the hostile title:
const rowTemplate = document.querySelector("#task-row");
function renderTask(task) {
const li = rowTemplate.content.firstElementChild.cloneNode(true);
li.dataset.id = String(task.id);
li.classList.toggle("done", task.done);
li.querySelector(".toggle").checked = task.done;
li.querySelector(".title").textContent = task.title;
li.querySelector(".delete").setAttribute("aria-label", `Delete ${task.title}`);
return li;
}
function check(label, actual, expected) {
console.log(`${actual === expected ? "PASS" : "FAIL"} ${label}: ${JSON.stringify(actual)}`);
}
const done = renderTask({ id: 7, title: "Buy detergent", done: true });
check("id on the row", done.dataset.id, "7");
check("done class", done.classList.contains("done"), true);
check("checkbox ticked", done.querySelector(".toggle").checked, true);
check("button label", done.querySelector(".delete").getAttribute("aria-label"), "Delete Buy detergent");
const hostile = `<b>bold</b> & "quoted"`;
const row = renderTask({ id: 8, title: hostile, done: false });
check("title shown as typed", row.querySelector(".title").textContent, hostile);
check("no elements created from the title", row.querySelector(".title").children.length, 0);
check("not done", row.classList.contains("done"), false);
PASS id on the row: "7" PASS done class: true PASS checkbox ticked: true PASS button label: "Delete Buy detergent" PASS title shown as typed: "<b>bold</b> & \"quoted\"" PASS no elements created from the title: 0 PASS not done: false
In a real project you run tests like these with a test runner such as Vitest (see Testing) and a simulated DOM, a library such as jsdom or happy-dom that implements document in Node.js, so tests run without opening a browser. Libraries like Testing Library add helpers that find elements the way users do, by their visible text and their accessible role ("the button called Delete Buy detergent"), which keeps tests working when you change class names. For behaviour that depends on real layout, such as scrolling or what is visible on a small screen, teams add a few end-to-end tests in a real browser with tools like Playwright.
In production
- Security. Every value that came from outside your code goes in with
textContent,valueorsetAttribute. Search your code base forinnerHTML,outerHTML,insertAdjacentHTMLanddocument.write, and check each one. EvensetAttributeneeds care withhrefandsrc: a link tojavascript:…runs code when clicked, so only accepthttp:andhttps:addresses from users. Browsers also offer Trusted Types and Content Security Policy, which block dangerous sinks for the whole page. - Performance. Build off-page (fragments, templates) and insert once; read layout before writing; don't rebuild a list of 10,000 rows on every keystroke. Very long lists show only the rows on screen ("virtual scrolling").
- Rebuilding loses state.
replaceChildrenmakes new elements, so focus and scroll position inside the list are lost on each render. That is fine here; for large, interactive pages, UI libraries such as React, Vue and Svelte compare the new output with the current page and change only what differs. They are built on exactly the DOM calls in this lesson. - Accessibility. Use the element that means what you want: a
<button>for actions (keyboard and screen readers support it for free, a clickable<div>gets nothing),<label>for inputs, andaria-liveorrole="alert"for messages that change. - Missing elements. Look up the elements your code depends on once, at start-up, and fail with a clear message if one is missing, as
mustFinddid.
Practice
TRY IT YOURSELF
Show only open tasks
Add a filter to the task list without rebuilding any rows. Write showOnly(filter), where filter is "all", "open" or "done". It sets the hidden property of each row and returns how many rows are visible. Test it on three rows made from the template.
Show a solution
const list = document.querySelector("#tasks");
const rowTemplate = document.querySelector("#task-row");
for (const [title, done] of [["Buy detergent", false], ["Pay the electricity bill", true], ["Clean the Okafor flat", false]]) {
const li = rowTemplate.content.firstElementChild.cloneNode(true);
li.classList.toggle("done", done);
li.querySelector(".title").textContent = title;
list.append(li);
}
function showOnly(filter) {
let visible = 0;
for (const li of list.children) {
const isDone = li.classList.contains("done");
li.hidden = (filter === "open" && isDone) || (filter === "done" && !isDone);
if (!li.hidden) visible++;
}
return visible;
}
console.log("open:", showOnly("open"));
console.log("done:", showOnly("done"));
console.log("all:", showOnly("all"));
open: 2 done: 1 all: 3
Hiding keeps the elements (and anything the user typed in them) and is cheap. In the render-from-state design you would more often store the filter in state and let render() skip the rows; both are fine, as long as there is one source of truth for which filter is active.
TRY IT YOURSELF
Highlight a search term safely
Write highlight(element, text, term) that shows text inside element with every case-insensitive match of term wrapped in a <mark>. It must be safe for any text, so no innerHTML: build text nodes and <mark> elements yourself.
Show a solution
function highlight(element, text, term) {
const parts = [];
const lower = text.toLowerCase();
const needle = term.toLowerCase();
let from = 0;
let at = needle === "" ? -1 : lower.indexOf(needle);
while (at !== -1) {
parts.push(text.slice(from, at));
const mark = document.createElement("mark");
mark.textContent = text.slice(at, at + needle.length);
parts.push(mark);
from = at + needle.length;
at = lower.indexOf(needle, from);
}
parts.push(text.slice(from));
element.replaceChildren(...parts.filter((p) => p !== ""));
}
const box = document.querySelector(".summary");
highlight(box, "Clean the flat, then clean the car", "clean");
console.log(box.innerHTML);
highlight(box, `<img src="data:," onerror="window.stolen = 1"> invoice`, "invoice");
console.log(box.querySelectorAll("img").length, box.querySelector("mark").textContent);
<mark>Clean</mark> the flat, then <mark>clean</mark> the car 0 invoice
Strings passed to replaceChildren become text nodes, so the attacker's tag stays text. The marks keep the original capitalisation because the slice comes from text, while the search uses the lower-case copy. An empty term would match everywhere, so it is treated as "no match".
TRY IT YOURSELF
Fix the renderer
A colleague wrote this renderer. Find three problems and rewrite it with the tools from this lesson.
function render(tasks) {
const list = document.querySelector("#tasks");
list.innerHTML = "";
for (const task of tasks) {
list.innerHTML += `<li data-id="${task.id}">${task.title}</li>`;
}
}Show a solution
- XSS:
task.titleis parsed as HTML. A title with anonerrorattribute runs code for every user who sees the list. - Slow: every
+=turns the whole list back into a string and parses all of it again, so rendering n tasks does work proportional to n², and every earlier row is thrown away and rebuilt each time. - Silent failure: if
#tasksis missing, the error is a confusingTypeErroronnull.
function render(tasks) {
const list = document.querySelector("#tasks");
if (!list) throw new Error("Page is missing #tasks");
const fragment = document.createDocumentFragment();
for (const task of tasks) {
const li = document.createElement("li");
li.dataset.id = String(task.id);
li.textContent = task.title;
fragment.append(li);
}
list.replaceChildren(fragment);
}
render([
{ id: 1, title: "Buy detergent" },
{ id: 2, title: `<img src="data:," onerror="window.stolen = 1">` },
]);
const list = document.querySelector("#tasks");
console.log(list.children.length, list.querySelectorAll("img").length);
console.log(list.lastElementChild.textContent);
2 0 <img src="data:," onerror="window.stolen = 1">
Summary
- The browser parses HTML into the DOM, a tree of node objects. Elements are one kind of node; text (including whitespace between tags) and comments are others. Prefer the element-only properties:
children,firstElementChild,nextElementSibling,parentElement. - Find elements with
querySelectorandquerySelectorAll(a static snapshot),closestandmatches. Expectnull, and fail with a clear message for elements the page needs. - Create with
createElement, insert withappend,prepend,before,after,replaceChildren, remove withremove(). Inserting an element that is already in the page moves it. - Attributes are the strings in the HTML; properties are the live, typed values on the object. Read form input from
valueandchecked. UseclassListanddataset, and remember boolean attributes are on when present. - User data goes in with
textContent, neverinnerHTML: otherwise it is an XSS hole. - Build many nodes off the page in a
DocumentFragmentor from a<template>, and insert them once. - Keep the data in state and render the page from it; test by reading the page back.
Next: Events, where the task list's buttons and a shopping cart come alive with listeners, bubbling and event delegation.
Test yourself
Five questions, picked at random from this lesson's question bank. Some ask you to choose an answer, some to predict what code prints, and some to write code and run it in the terminal. Get 4 of 5 right to pass. If you don't, read the explanations and try again: you get 5 different questions.