The Document Object Model (DOM) is the browser's in-memory representation of your page. JavaScript can read and modify it. This lesson covers querying elements, changing text and attributes, creating and removing nodes, registering event handlers, and the event delegation pattern that makes interactive pages fast.
Querying the DOM
Use querySelector and querySelectorAll to find elements with a CSS selector. The single-selector version returns the first match or null. The all version returns a NodeList that you can iterate with for-of or convert with Array.from.
// In a page with <ul id="todos"><li>a</li><li>b</li></ul>
const list = document.querySelector("#todos");
const items = document.querySelectorAll("#todos li");
console.log(list.tagName); // UL
console.log(items.length); // 2
for (const li of items) {
console.log(li.textContent); // a b
}
Reading and writing content
textContent reads and writes the text inside an element, including all descendants. innerHTML reads and writes HTML. Prefer textContent when the value is plain text, to avoid HTML injection bugs. Set attributes with setAttribute and remove them with removeAttribute.
const heading = document.querySelector("h1");
heading.textContent = "Hello, World!";
const link = document.querySelector("a#home");
link.setAttribute("href", "/");
link.removeAttribute("target");
const box = document.querySelector("#status");
box.dataset.state = "loading"; // sets data-state="loading"
console.log(box.dataset.state); // loading
Creating, appending, and removing nodes
createElement builds a new element. classList.add toggles classes. append, prepend, and remove move nodes in the tree. The modern DOM methods accept multiple nodes and strings, which removes much of the boilerplate older code had.
const ul = document.querySelector("#todos");
for (const text of ["buy milk", "write code", "ship it"]) {
const li = document.createElement("li");
li.textContent = text;
li.classList.add("task");
ul.append(li);
}
// Remove the first one
ul.firstElementChild?.remove();
Registering event handlers
addEventListener attaches a handler that fires when the event reaches the element. The handler receives an Event object with target, type, and a preventDefault method. Use addEventListener over the on... properties because it lets you attach multiple handlers and gives you the capture/bubble options.
const button = document.querySelector("#save");
button.addEventListener("click", (event) => {
event.preventDefault();
console.log("save clicked at", event.clientX, event.clientY);
saveData();
});
const input = document.querySelector("#email");
input.addEventListener("input", (event) => {
console.log("current value:", event.target.value);
});
Event delegation for dynamic lists
Attach one handler to a stable parent and use event.target to figure out which child fired. This is faster than attaching handlers to every child and works for elements added after the page loads.
const list = document.querySelector("#todos");
list.addEventListener("click", (event) => {
const li = event.target.closest("li");
if (!li || !list.contains(li)) return;
li.classList.toggle("done");
});
Summary
Use querySelector and querySelectorAll to find elements. textContent and classList are your workhorses. append and remove handle tree changes. addEventListener attaches handlers, and event delegation on a stable parent is the pattern for dynamic lists. The next lesson covers testing in JavaScript with the standard test framework, Vitest, which is the modern alternative to Jest.
MutationObserver and IntersectionObserver
Two modern browser APIs help you react to DOM changes. MutationObserver fires a callback whenever the observed subtree changes (children added, attributes modified, text updated). Use it to keep a third-party widget in sync with your state. IntersectionObserver fires when an element enters or leaves the viewport; use it for lazy loading images or animating elements as they scroll. Both APIs replace older techniques like polling and scroll listeners that hurt performance.
// Watch for new items being added to a list
const observer = new MutationObserver((records) => {
for (const r of records) {
for (const node of r.addedNodes) {
console.log("added", node);
}
}
});
observer.observe(document.querySelector("#todos"), { childList: true });
// Lazy-load an image when it scrolls into view
const io = new IntersectionObserver((entries) => {
for (const e of entries) {
if (e.isIntersecting) {
const img = e.target;
img.src = img.dataset.src;
io.unobserve(img);
}
}
});
io.observe(document.querySelector("img.lazy"));
Performance tips for the DOM
The DOM is the slow part of the browser. Reading layout properties (offsetHeight, getBoundingClientRect) inside a tight loop forces the browser to reflow, which is expensive. Batch reads, then batch writes. Use DocumentFragment to build a subtree off the live DOM, then attach it once. Avoid querySelector inside hot loops; cache the lookup result. When you need to animate, prefer CSS transitions over JavaScript timers; the browser can hand off animated properties to the GPU.
// Build off the live DOM, then attach
const frag = document.createDocumentFragment();
for (const text of ["one", "two", "three", "four"]) {
const li = document.createElement("li");
li.textContent = text;
frag.append(li);
}
document.querySelector("#todos").append(frag); // single layout
XSS and safe DOM updates
The most dangerous DOM bug is cross-site scripting: injecting attacker-controlled text into innerHTML or eval. The fix is to set textContent instead of innerHTML whenever you can, and to sanitize HTML with DOMPurify or a similar library when you must set innerHTML. Never use eval, Function(string), or setTimeout(string, ms) with attacker-controlled input; they execute arbitrary code. Treat every value from the URL, the network, or user input as untrusted. The default should be the safe API; reach for the dangerous one only when you have a clear reason.
Comparison with Python
Python's standard library does not include a DOM because Python is rarely used in the browser. For web scraping Python has BeautifulSoup, lxml, and the Playwright or Selenium bindings, which build and query a DOM in a headless browser. The mental model is the same: find elements with CSS selectors or XPath, read attributes, modify children, listen for events. The biggest practical difference is that Python DOMs run on the server side and return data to the caller, while JavaScript runs in the browser and updates the live page. The DOM API in browsers is standardized and consistent; Python's parsing libraries each have their own quirks.