Chapter 19 — DOM Manipulation
Section 1 — The DOM, remembered
The DOM is the browser's live, tree-shaped model of your page, built from your HTML. Each element, piece of text, and attribute becomes a node in that tree, and JavaScript can read and change those nodes while the page is running. The word "tree" is not just a metaphor: like a family tree, the DOM has a root, branches, and leaves, with parents, children, and siblings. The root is the html element, its children are head and body, and everything visible on the page hangs below body in nested layers. When you indent HTML in your editor, you are drawing that tree in text form, and the browser turns that indentation into a real structure it can navigate.
It is important to keep HTML and the DOM distinct, because confusing them causes real debugging trouble. HTML is the source text you write, a static file that arrives from a server. The DOM is what the browser builds from that text, a live structure that JavaScript can change after loading. If a script changes a heading's text, the HTML file does not change, and reloading the page restores the original text, because the DOM is rebuilt from the HTML on every load. Conversely, the DOM can differ from the HTML for innocent reasons: the browser fixes up missing closing tags, moves misplaced elements, and adds elements that scripts create. View Source shows the HTML as sent; the Elements panel in developer tools shows the DOM as it is now, including all those changes, which is why Inspect is the tool to trust when a page does not match its source.
Thinking in trees helps you predict how DOM code behaves. A change to a parent affects the area its children occupy, because layout flows down the tree. A search that starts at document covers the whole tree, while a search that starts at one card covers only that card's branch. Removing a branch removes everything below it, which is why deleting a list element also deletes its items, without needing to delete each item separately. That hierarchical thinking — root, branch, leaf, and the paths between them — is the mental model for the whole chapter. Every method you learn is either finding a node in the tree, changing a node, creating a new branch, or removing one, and once you see the tree, those operations feel like natural movements within it rather than a list of unrelated functions.
Practise seeing the tree in a page you already know. Open a simple page, open developer tools, and expand the Elements panel node by node, naming each relationship as you go: this heading is a child of this section, which is a child of the body, and the next paragraph is its sibling. That slow walk through a real tree makes the abstract idea concrete, and it prepares you for the finders in the next sections, which are simply ways of pointing at a node in that tree without walking it by hand. The DOM is not an extra concept to memorise on top of HTML; it is HTML, understood as a structure your code can touch.
console.log(document.documentElement.tagName); // "HTML"
console.log(document.body.children.length);
Check: Is the DOM your HTML file, or the browser's live model built from it?
Section 2 — The document object
In JavaScript, the whole page is available as document. It is your starting point: every search for an element begins from document, and it also represents the page itself, with properties like its title, its URL, and its body element. When you write document.querySelector("h1"), you are asking the document — the page as a whole — to find the first heading anywhere in its tree. When you write document.title, you are reading a property of the page, which comes from the title element in the head. The document object is therefore both a gateway to the tree and a description of the page, and it is available in every script that runs in a browser, without any setup, because the browser creates it as part of loading the page.
The document object sits below window in the browser's object hierarchy, though you rarely need to write window.document, because document is available as a global. window represents the browser window itself, with its size, its history, and its timers, while document represents the page inside that window. That division is useful: window-level things, like resizing or scrolling the whole window, belong to window, while page-level things, like finding elements or changing text, belong to document. You will meet window again in the events chapter, for events like load and resize, but for DOM manipulation, document is the object you will use most, and it is worth getting comfortable with its role as the root of all page operations.
Several properties of document are handy shortcuts to parts of the tree. document.body gives you the body element directly, without a search, and document.head gives you the head. document.documentElement gives you the root html element. Those shortcuts save a search when you need a top-level element, and they also demonstrate the tree idea: the document is not itself an element, but it holds references to the elements that make up the page, starting with the root. Other properties, like document.URL and document.title, describe the page's metadata, and changing document.title changes the text shown in the browser tab, which is a visible, immediate example of the DOM being live: assign a new title, and the tab updates at once, without reloading.
A good first exercise with document is exploration in the console. Type document and inspect the object the console shows, then try document.body, document.title, and document.URL on a page you are visiting, and watch each one return a real, current value. That exploration builds familiarity with the idea that document is not an abstract concept but a concrete object holding the page you are looking at, ready to be searched and changed. Every finder in the next sections starts from this object, so understanding it as the page's front door makes the rest of the chapter easier to follow.
console.log(document.title);
console.log(document.body.tagName); // "BODY"
console.log(document.documentElement.tagName); // "HTML"
document.title = "My Updated Title";
Check: What is the starting point in JavaScript for finding elements on a page?
Section 3 — Finding an element by its ID
document.getElementById("main-title") returns the element whose id is main-title, or null if there is none. Because IDs are unique on a page — the HTML rules say an ID should appear only once — this method always returns at most one element, not a list, which makes it simple to use: you get an element, or you get nothing, and you handle both cases. The argument is the ID value without the hash sign: in HTML you write id="main-title", and in JavaScript you write getElementById("main-title"), not getElementById("#main-title"). That missing hash is a common beginner mistake, borrowed from CSS selectors, where the hash is required, and it produces a silent null rather than an error, so it is worth checking first when this method returns nothing unexpectedly.
IDs are best used for elements that are genuinely singular on a page: the main heading, the search box, the navigation menu, the results container. Those are the elements a script needs to find reliably, by a stable name, regardless of how the rest of the page changes. Using an ID for a repeated thing, like a card in a list, is wrong, because only the first such element would be found, or the page would be invalid, and the other cards would be unreachable by that ID. For repeated things, classes and querySelectorAll() are the right tools, as the next sections show. The discipline is: one of a kind gets an ID, many of a kind get a class, and that discipline keeps both your HTML valid and your JavaScript predictable.
The null result deserves careful handling, because it is the source of many "cannot read property of null" errors. When getElementById() returns null, it means no element with that ID exists at the moment the code ran. That can happen because the ID is misspelled, because the element is created later by another script, or because the script ran before the HTML was parsed, a timing issue covered in Section 28. The fix is never to ignore the possibility: check the result before using it, as in if (heading) { ... }, or use optional chaining, and investigate why the element is missing rather than assuming it will appear. Logging the result while learning — console.log(document.getElementById("main-title")) — shows you immediately whether the search succeeded, and it turns a mysterious null error into a visible, checkable fact.
getElementById() is also fast, because the browser can maintain an index of IDs, unlike a class or tag search that must walk the tree. That speed rarely matters in beginner projects, but it is a reason this older method has survived alongside the more flexible querySelector(): when you know the ID, it is the most direct route to the element. In practice, many developers now use querySelector("#main-title") for consistency, since it uses the same selector language as everything else, and either form is acceptable. What matters is not which finder you choose, but that you understand what it returns — one element or null — and that you handle the null case.
const heading = document.getElementById("main-title");
console.log(heading); // the element, or null
if (heading) {
heading.textContent = "Found by ID";
}
const missing = document.getElementById("does-not-exist");
console.log(missing); // null
Check: What does getElementById() return when no element has that ID?
Section 4 — querySelector: the flexible finder
document.querySelector() finds the first element matching any CSS selector: a tag, a class, an ID, or a combination. You already know CSS selectors from styling, and querySelector() lets you reuse that knowledge for finding elements, which is why it is the most versatile finder in the DOM. querySelector("h1") finds the first heading, querySelector(".note") finds the first element with class note, querySelector("#main-title") finds the element with that ID, and querySelector("article.card h2") finds the first heading inside a card article. The selector language is the same one you use in stylesheets, including descendant combinations, child combinations, and attribute selectors, so every selector you can write in CSS, you can also use to find an element in JavaScript.
The word "first" in the description is important. querySelector() returns only one element: the first match in document order, which is the order elements appear in the HTML, reading top to bottom. If there are ten cards on a page, querySelector(".card") returns the first card only, and the other nine are ignored. That is correct when you want one specific element, such as the first heading, the search box, or the main container, but it is a bug when you meant to update all matching elements, in which case querySelectorAll() is the tool, as the next section explains. When a querySelector() update seems to work on only one item, the usual cause is exactly this: the code found the first match and stopped, because that is what the method is designed to do.
Like getElementById(), querySelector() returns null when nothing matches, and the same guarding habit applies: check the result before using it, especially when the selector is complex or the element may not exist yet. A complex selector is also more fragile than a simple one, because it depends on the page's structure staying the same. A selector like div > section:nth-child(2) > h2 will break if a wrapper is added or a section is reordered, while a selector based on a meaningful class, like .card-title, survives structural changes. The guideline is to select by meaning — a class or ID that describes the element's role — rather than by position in the tree, unless position is genuinely the point. Meaningful selectors are easier to read, easier to debug, and more resilient to redesigns, which is why professional code prefers them.
querySelector() also works on an element, not just on document, and then it searches only inside that element, a feature covered in Section 29. That scoped search is how you find, for example, the heading inside one particular card, rather than the first heading on the whole page, and it is a natural extension of the same idea: the search starts from wherever you call it, and covers only the tree below that point. For now, practise with document-level searches, using simple, meaningful selectors, and always log the result when a search surprises you.
const firstNote = document.querySelector(".note");
const heading = document.querySelector("h1");
const cardTitle = document.querySelector("article.card h2");
console.log(firstNote, heading, cardTitle);
const card = document.querySelector(".card");
console.log(card); // first card only
Check: Write a querySelector call that finds the first element with class "card".
Section 5 — querySelectorAll: finding every match
document.querySelectorAll() returns all elements matching a selector, as a NodeList — a list-like collection, not a true array. The distinction matters because a NodeList has a length and can be looped over, but it does not have array methods like map() or filter(). You can loop over it with for...of or with its own forEach() method, which modern browsers provide on NodeLists, but if you need map() or filter(), you must first convert it to a real array, for example with [...nodeList] or Array.from(nodeList).
A NodeList from querySelectorAll() is static, meaning it is a snapshot taken at the moment of the call. If you add a new matching element afterwards, the NodeList you already hold does not update to include it; you must call querySelectorAll() again to get a fresh list. That static behaviour contrasts with the live collections from older methods like getElementsByClassName(), covered in Section 31, and it is generally safer, because a list that does not change under you while you loop over it is easier to reason about. The trade-off is that code which adds elements and then expects an old NodeList to include them will miss the new elements, and the fix is to re-query after changes, or to query inside the function that needs the current list, rather than holding a list from long ago.
The most common use of querySelectorAll() is applying a change to a whole group: adding a class to every price, hiding every note, or reading the value of every checked checkbox. The pattern is always the same — query, then loop — and it is the DOM equivalent of looping over an array of data. Because the loop variable holds one element at a time, you can call any element method on it inside the loop, such as setting text, toggling a class, or reading an attribute, and the change applies to each element in turn. That uniformity is powerful: one loop handles ten elements or a hundred, without any extra code, which is the same advantage arrays give for data, now applied to the page itself.
A mistake to watch for is treating the NodeList as a single element. Calling querySelectorAll(".card").textContent = "Hi" does nothing useful, because the NodeList itself has no textContent property; only its individual elements do. When a group update silently fails, check whether you forgot the loop: did you query for many elements but then act as if you had one? The fix is always to loop, even if you expect only one match, because querySelectorAll() promises a list, and lists need looping. Conversely, when you only need one element, use querySelector(), not querySelectorAll()[0], because the singular method states the intent more clearly and avoids the indexing step. Choosing the method that matches the count you expect — one or many — is a small discipline that prevents a surprising number of DOM bugs.
const cards = document.querySelectorAll(".card");
console.log(cards.length);
cards.forEach(function (card) {
card.classList.add("seen");
});
const cardArray = [...document.querySelectorAll(".card")];
const titles = cardArray.map(function (card) {
return card.querySelector("h2").textContent;
});
console.log(titles);
Check: Why can't you call map() directly on the result of querySelectorAll()?
Section 6 — textContent, innerText and innerHTML
textContent gets or sets the plain text inside an element, ignoring HTML tags. innerHTML gets or sets the HTML markup inside it, so assigning to innerHTML parses the string as markup and builds new elements from it. innerText is similar to textContent but respects what is visibly rendered: it accounts for styling, such as hidden elements and line breaks from CSS, and it triggers a layout calculation, which makes it slower and less predictable than textContent. For most work, the choice is between textContent for text and innerHTML for trusted markup, with innerText reserved for the rare case where you genuinely need the rendered text as a user would see it, including its line breaks.
The safety difference is the reason to learn these three properties together. Setting textContent to a string that contains tags, like "<b>Hi</b>", shows those characters literally, because textContent never parses its input as markup. That makes it safe for user input: a comment containing tags cannot become real elements, it just displays as text. Setting innerHTML to the same string creates a real bold element, which is powerful for trusted markup you wrote yourself, but dangerous for untrusted input, because a malicious string could inject a script or a misleading element, a risk covered in depth in Section 23. The rule is therefore simple to state and important to follow: text from users or from outside your code goes through textContent, and only markup you wrote and trust goes through innerHTML.
Reading the three properties also differs. Reading textContent gives you all the text inside an element, including text inside hidden children and inside script or style elements, concatenated without regard to layout. Reading innerText gives you only the visible text, with line breaks where the rendering puts them, which can be useful for copying visible content but surprising if you expected hidden text to be included. Reading innerHTML gives you the markup as a string, including tags and attributes, which is useful for inspecting structure but not for reading the words a visitor sees. When you log a property while debugging and the result surprises you, check which of the three you read: the wrong choice explains most surprises, such as missing hidden text or unexpected tags in the output.
A practical guideline covers almost every case you will meet. To display a value — a name, a count, a message — use textContent. To build a structure from a template or a trusted string — a card layout you designed — use innerHTML sparingly, or better, use createElement() and textContent together, as later sections show, which gives you the structure without the parsing risk. To read what a visitor can actually see, use innerText only if you have a specific reason, and otherwise use textContent for its speed and predictability. Keeping those three jobs separate — display text, trusted markup, visible text — prevents the common mistake of reaching for innerHTML for every update simply because it is the most familiar, when textContent would have been safer and clearer.
const el = document.querySelector("p");
console.log(el.textContent);
el.textContent = "Plain text only";
el.innerHTML = "<strong>Trusted markup</strong>";
console.log(el.innerHTML); // "<strong>Trusted markup</strong>"
Check: Which property should you use to set plain text, and why not innerHTML for that job?
Section 7 — Changing an element's text
Assigning to textContent replaces everything inside an element with new plain text. If the element previously held child elements, they are removed, and only the new text remains, which means textContent assignment is a replacement, not an addition. That behaviour is exactly what you want for a heading, a status message, or a count that should show one current value, and it is the wrong tool for adding a line to a list while keeping the existing lines, where you should append a new element instead. Understanding replacement versus addition prevents the bug where a status update wipes out a button that lived inside the same container, because the button was a child element and textContent assignment removes all children.
The safety of textContent assignment, noted in the previous section, deserves emphasis because it shapes how you handle user input throughout a project. A visitor's name, a comment, a search term, or any text that came from outside your code should be displayed with textContent, never with innerHTML, because textContent guarantees that the text cannot become markup. Building a habit of defaulting to textContent for all displayed values, and only switching to a markup method when you have a deliberate reason and trusted content, protects every page you build from a whole class of injection bugs without any extra effort.
Changing text is also the simplest form of keeping a page in step with data, which is the theme of this chapter's later sections. A counter that shows the number of tasks, a greeting that shows the signed-in user's name, or a message that shows the result of an action are all textContent assignments driven by a change in data. The pattern is: data changes, then the display element's text is updated from the data, in one line. That one line is the bridge between the data world of Chapter 18 and the visible world of the page, and it is worth practising until it feels trivial, because almost every interactive feature includes at least one such update. When a display looks stale — showing an old count after a task was added, for example — the usual cause is a missing text update after a data change, not a problem with the data itself.
There is a small performance and correctness note: assigning textContent is cheap for a single element, and it does not require the browser to parse markup, so it is faster than the equivalent innerHTML assignment. It also preserves no child elements, which is a feature when you want a clean replacement, but a trap when the element holds structure you wanted to keep, such as a heading containing a span for styling. In that case, target the specific text node or the specific child element that holds the text, rather than the whole container, so the structure survives.
document.querySelector("h1").textContent = "Welcome back!";
const status = document.querySelector("#status");
status.textContent = "Saved successfully";
const tricky = document.querySelector("#comment");
tricky.textContent = "<b>Hi</b>"; // shows literally, not bold
Check: If you set textContent to "<b>Hi</b>", what will the visitor see?
Section 8 — Reading and changing attributes
getAttribute() reads an attribute, setAttribute() sets or changes one, and removeAttribute() deletes one. This is how you change a link's destination, an image's source, or a form field's placeholder, all of which live in HTML as attributes. The attribute name is written exactly as in HTML, in lowercase, such as "href", "src", or "alt", and the value is always a string, even for attributes that look numeric, like a width. Reading an attribute that does not exist gives null, which is a useful signal: it tells you the attribute was never written in the HTML, as opposed to being present with an empty value, which would give an empty string. That distinction, null versus empty, helps you decide whether to add an attribute or to clear an existing one.
Many attributes also have a corresponding property on the element, and the two are related but not identical, a distinction covered in Section 47. For a link, the href property returns the full, resolved URL, while the href attribute returns exactly what was written in the HTML, which might be a relative path. For an image, the src property similarly resolves to a full URL. For most everyday changes, either the attribute method or the property assignment works — link.href = "https://example.com" and link.setAttribute("href", "https://example.com") both change where the link goes — but the attribute methods are the explicit, uniform way to handle any attribute, including custom ones that have no property counterpart, such as data- attributes handled through dataset in Section 19, or accessibility attributes like aria-expanded. Learning the three attribute methods therefore gives you a tool that works for every attribute, not just the familiar ones.
Changing an attribute takes effect immediately, and the browser reacts to the new value as it would to the original HTML. Changing an image's src starts loading the new picture, changing a link's href changes where a click will go, and changing an input's placeholder changes the hint text shown when the field is empty. That immediacy is what makes attribute changes useful for interactive features: a gallery that swaps pictures on thumbnail clicks is just a series of src changes, and a theme switcher that swaps a stylesheet link is a series of href changes. The pattern is always the same: an event, covered in the next chapter, triggers an attribute change, and the browser updates the display in response, without any page reload.
When you change an attribute, remember to change related attributes that describe the same thing, a point Section 36 returns to for images. Changing an image's src without changing its alt leaves a description that no longer matches the picture, which misleads screen-reader users and search engines. Changing a link's href without changing its text can leave a label that promises one destination and delivers another. Attributes, like properties, describe an element together, and a change to one often implies a change to its companions.
const img = document.querySelector("img");
img.setAttribute("src", "new-photo.jpg");
img.setAttribute("alt", "A new photo");
console.log(img.getAttribute("alt"));
const link = document.querySelector("a");
link.setAttribute("href", "https://example.com");
link.removeAttribute("title");
Check: How do you change the destination of a link with JavaScript?
Section 9 — classList: managing classes
classList is the clean way to manage an element's classes: add() adds one, remove() removes one, toggle() adds it if absent and removes it if present, and contains() checks for it. Prefer this to editing the whole class string by hand, because the class string is a space-separated list, and string manipulation of such a list is error-prone: adding a class by concatenation can duplicate it, removing one by replacement can leave double spaces or remove part of another class name, and checking for one by searching the string can match a substring of a longer class name. classList handles all of those edge cases correctly, because it treats classes as a set, where each class is either present or absent, never duplicated, and never partially matched.
toggle() deserves special attention, because it is the basis of the toggle pattern in Section 37. Calling toggle("open") on an element without that class adds it, and calling it again removes it, so a single line flips the state, without needing an if to check the current state first. That flip behaviour is exactly what a menu button, a dark-mode switch, or an expandable panel needs: each click reverses the previous state, and the code does not need to track the state separately, because the class itself is the state. toggle() also accepts a second, optional argument that forces the result: toggle("open", true) always adds, and toggle("open", false) always removes, which is useful when the desired state is known from data rather than from a flip, for example setting a class based on whether a condition is true.
contains() is the test that pairs with the other three methods. It answers "does this element currently have this class?" with true or false, which is useful for decisions that depend on state, such as "if the panel is open, close it, otherwise do something else", or for counting how many elements in a group are in a particular state. Combined with querySelectorAll() and a loop, contains() lets you inspect a whole group: how many cards are selected, which tabs are active, or whether any item is highlighted. That inspection, plus the add, remove, and toggle operations, covers almost all class-based interaction, because classes are the bridge between JavaScript behaviour and CSS appearance: JavaScript changes the class, CSS defines what that class looks like, and the separation keeps each layer doing its own job.
A style note: class names should describe meaning, not appearance, so that a class change means a state change, not just a colour change. A class called open, selected, or error describes a state, and its CSS can change colour, layout, or visibility as needed, without the JavaScript caring. A class called red or big describes only appearance, and changing it from JavaScript mixes the layers, because the script must then know about styling details. That naming discipline, applied consistently, makes toggle code readable: card.classList.toggle("selected") tells a reader that the card's selection state flipped, while card.classList.toggle("yellow") tells them only that a colour changed, without explaining why.
const card = document.querySelector(".card");
card.classList.add("highlight");
console.log(card.classList.contains("highlight")); // true
card.classList.toggle("open");
card.classList.remove("highlight");
card.classList.toggle("open", true); // force add
Check: What does classList.toggle("open") do if the element already has class "open"?
Section 10 — Changing styles directly
The style property sets inline styles on an element, using camelCase property names: backgroundColor for background-color, fontSize for font-size, and marginTop for margin-top. The conversion rule is simple: remove the hyphen and capitalise the next letter, because a hyphen is not allowed in a JavaScript identifier, so CSS property names must be translated to work as property names. A few properties have special names, such as cssFloat for float, because float is a reserved word, but those exceptions are rare, and the camelCase rule covers the properties you will use daily. Values are strings, including their units where needed, as in "20px" or "1.5em", and assigning a number without a unit to a property that needs one will usually be ignored, so include the unit explicitly.
Inline styles set through style override stylesheet rules, because inline styles have high specificity in the cascade, which means a style assignment will beat a class rule for the same property, even if the class rule comes later in a stylesheet. That power is useful for values that change with user interaction or with data — a progress bar's width, a drag position, or a colour chosen by a user — because those values cannot be known in advance and written as classes. It is less suitable for steady styling, where a class is clearer, more reusable, and easier to change in one place. The guideline is: if the value is fixed and shared, use a class; if the value is dynamic and specific to this element at this moment, use style.
Reading style only shows inline styles set directly on the element, not styles from stylesheets, which is a common source of confusion covered in Section 26. If a stylesheet sets an element's colour to blue, element.style.color returns an empty string, because no inline colour was set, even though the element visibly is blue. That emptiness does not mean the element has no colour; it means the colour came from elsewhere, and getComputedStyle() is needed to read the final, applied value. For writing, however, style is straightforward: assign a value, and the element updates immediately, with the inline style taking precedence. To remove an inline style and let the stylesheet take over again, assign an empty string, as in element.style.backgroundColor = "", which clears that inline property without touching any others.
Direct style changes are best kept small and purposeful. Setting one or two properties in response to an interaction is clear; setting ten properties in a row is usually a sign that a class should have been toggled instead, with those ten properties defined together in CSS. So use style for the genuinely dynamic values — positions, sizes from data, colours from a picker — and use classes for states and themes, and your code will stay readable, with each layer doing the job it does best.
const box = document.querySelector(".box");
box.style.backgroundColor = "yellow";
box.style.fontSize = "20px";
console.log(box.style.backgroundColor); // "yellow"
box.style.backgroundColor = ""; // clear the inline style
Check: In JavaScript, what is the style property name for CSS background-color?
Section 11 — Creating a new element
document.createElement() makes a new element that is not yet on the page. You pass the tag name as a string, such as "li", "p", or "article", and you get back a fresh element of that type, with no children, no text, and no attributes beyond the defaults for that tag. Creating alone does not display anything, because the new element is not connected to the document's tree yet; it exists in memory, detached, waiting to be configured and inserted. That detached stage is useful, because it lets you set the element's text, classes, and attributes in full, before it ever appears on screen, so the visitor never sees a half-built element flash into view. Build first, insert once, is the pattern, and it applies to single elements as well as to long lists.
Configuring a new element uses the same properties and methods as changing an existing one, which is reassuring: there is no separate "new element" API to learn. Set textContent for its text, use classList.add() for its classes, use setAttribute() for its attributes, and set style properties for dynamic styling, all before insertion. If the new element needs children of its own, create those children too, configure them, and append them to the new element while it is still detached, building the whole subtree off-page.
The tag name you pass must be a valid HTML tag name, in lowercase, though browsers are forgiving about case. Creating an unknown tag, like createElement("fancy"), produces a generic element that the browser will treat as an unknown inline element, which can be useful for custom elements in advanced work, but for ordinary pages you should stick to standard tags, so that the element has the right default behaviour, accessibility role, and styling. A list item should be an li, a paragraph should be a p, a button should be a button, because the tag carries meaning for screen readers, for search engines, and for default browser styles. Choosing the right tag at creation time is part of writing semantic HTML, even when the HTML is generated by JavaScript rather than written by hand.
A common mistake is to create an element and then forget to insert it, leaving a fully configured element that never appears, with no error to explain the absence. When a new item does not show up, check two things in order: was it created, which you can verify by logging it, and was it appended to a parent that is itself on the page, which you can verify by logging the parent and its children after the append. The second check catches the subtler bug of appending to a detached parent, or to the wrong parent, where the element exists in a tree that is not the visible page's tree.
const item = document.createElement("li");
item.textContent = "New task";
item.classList.add("task");
console.log(item); // detached, not yet on the page
document.querySelector("ul").appendChild(item);
const card = document.createElement("article");
card.classList.add("card");
const heading = document.createElement("h2");
heading.textContent = "Card title";
card.appendChild(heading);
Check: After createElement(), is the new element visible on the page? Why not?
Section 12 — Adding an element to the page
appendChild() adds a child at the end of a parent, while the newer append() can add several nodes or plain text at once. prepend() adds at the start instead of the end, so the new child becomes the first child of the parent, pushing existing children down by one position. The three methods together cover the common insertion positions — end, start, and multiple at once — and choosing between them is a matter of where the new content belongs in the order. New tasks usually go at the end of a list, so appendChild() is the default; newest-first feeds go at the start, so prepend() fits; and building a row from several cells at once might use append() with multiple arguments, saving repeated calls.
appendChild() returns the appended node, which is occasionally useful for chaining or for keeping a reference, but most code ignores the return value and simply calls the method for its effect. append() returns nothing, and it accepts strings as well as nodes, converting a string argument into a text node automatically, which is convenient for adding text without creating a text node explicitly. That string acceptance is also a safety note: append() with a string creates text, not markup, so it is safe for user input in the same way textContent is, unlike innerHTML, which would parse the string as markup.
Appending an element that is already on the page does not duplicate it; it moves it. If you append a list item that already lives in one list to a different list, it leaves the first list and appears in the second, because an element can have only one parent in the tree at a time. That move behaviour is useful for reordering and for moving items between containers, such as dragging a task from one column to another, but it can surprise you if you expected a copy. To duplicate an element, you must clone it first, as Section 16 explains, and then append the clone, leaving the original in place. Remembering "append moves, clone copies" prevents the bug where a list loses an item because it was appended elsewhere, with no error to explain the disappearance.
Insertion order matters for display, because the DOM order determines the visual order for most layouts, and screen readers also follow DOM order. Appending at the end puts new content after existing content, which matches the reading order for lists that grow downwards, while prepending puts it before, which matches newest-first feeds. When the visual order and the DOM order disagree — for example, when CSS reorders items visually — screen-reader users may experience a confusing order, so it is better to make the DOM order match the intended reading order, and to use append or prepend accordingly, rather than relying on CSS to reorder.
const list = document.querySelector("ul");
const item = document.createElement("li");
item.textContent = "New task";
list.appendChild(item);
const first = document.createElement("li");
first.textContent = "Urgent task";
list.prepend(first);
list.append("Plain text node", document.createElement("li"));
Check: What is the difference between appendChild() and prepend()?
Section 13 — Inserting in a precise spot
insertBefore(newNode, existingNode) inserts a node just before a chosen child, and it is the precise tool for positions that are neither the very start nor the very end. You call it on the parent, with the new node first and the reference child second, as in list.insertBefore(item, list.firstElementChild), which inserts at the start, or list.insertBefore(item, someMiddleChild), which inserts before that middle child. If the reference child is null, insertBefore() behaves like appendChild() and adds at the end, which is a useful fallback but also a source of confusion if the reference child was expected to exist and did not, because the insertion silently lands at the end instead of failing. Checking that the reference child is what you think it is, by logging it, prevents that silent misplacement.
insertAdjacentHTML() inserts HTML at a named position, with four position names that are worth memorising as a set: "beforebegin" means before the element itself, as a sibling; "afterbegin" means inside the element, at the start; "beforeend" means inside the element, at the end; and "afterend" means after the element itself, as a sibling. The names describe the position relative to the element's opening and closing tags: "before" and "after" are outside, "begin" and "end" are inside, and the combination picks one of the four spots. "beforeend" is the most common, because it appends HTML inside a container, like adding a row to the end of a list, without replacing the existing content. The method is convenient for trusted markup, but it shares innerHTML's safety warning: it parses its string as markup, so it must never be used with untrusted user input, where createElement() and insertBefore() or append() are the safe alternatives.
There are also newer, element-based companions to insertAdjacentHTML(): insertAdjacentElement() inserts an element at the same four positions, and insertAdjacentText() inserts text at those positions, safely, as text. Those methods combine the precise positioning of the adjacent family with the safety of element and text handling, and they are often the best choice when you need a precise spot for a single element or a text node. For example, inserting a new element immediately after a heading, as a sibling, is heading.insertAdjacentElement("afterend", newElement), which is clearer than finding the heading's parent and calling insertBefore() with the heading's next sibling.
When you need a precise insertion, start by naming the position in words: "before this item", "after this item", "at the start of this container", or "at the end of this container". That sentence maps directly onto a method: before an item is insertBefore() or "beforebegin", after an item is insertBefore() with the next sibling or "afterend", at the start of a container is prepend() or "afterbegin", and at the end is append() or "beforeend". Naming the position first, then picking the method, is more reliable than trying to remember method signatures in the abstract, because the position is the real requirement and the method is just its implementation.
const list = document.querySelector("ul");
const item = document.createElement("li");
item.textContent = "First task";
list.insertBefore(item, list.firstElementChild);
const heading = document.querySelector("h2");
heading.insertAdjacentHTML("afterend", "<p>Trusted paragraph</p>");
heading.insertAdjacentText("afterend", "Safe text");
Check: How do you insert a new item at the very start of a list?
Section 14 — Removing an element
The modern way to remove an element is element.remove(), which takes it out of the page entirely. After removal, the element is detached, like a newly created element that was never inserted: it still exists in memory if a variable holds it, and it can be reinserted elsewhere, but it is no longer part of the visible page's tree, and its children are removed with it, because removing a branch removes everything below it. That branch behaviour is convenient for deleting a card, a list item, or a whole section, because you do not need to delete each child separately, and it is also a warning: removing a container to delete one item inside it will delete all its siblings too, so target the smallest element that actually needs to go.
Older code used parent.removeChild(child), which does the same job from the parent's side and returns the removed child. You will still see removeChild() in older tutorials and codebases, so recognising it is useful, but for new code remove() is shorter, clearer, and does not require you to find the parent first, which is an advantage when you already hold the element to delete, for example from an event handler on a button inside a card. The pattern in that case is: from the button, find the card with closest() or parentElement, then call remove() on the card, in two readable steps. closest() is a helpful method here, because it walks up the tree looking for an ancestor matching a selector, so a button deep inside a card can find its card without assuming a fixed number of parent levels.
Removing an element does not automatically remove event listeners attached to it in a way that causes problems, because once the element is detached and no longer referenced, it becomes eligible for garbage collection, and its listeners go with it. However, if your code holds a reference to the removed element in a long-lived array or object, that element cannot be collected, and its listeners and data stay in memory, which can become a leak in a long-running page that creates and removes many elements. The practical habit is to drop references to removed elements when you no longer need them, for example by removing the corresponding data item from your data array at the same time as you remove its element, keeping data and display in step, as Section 39 advises.
A removed element can be reinserted, which is sometimes useful for moving content, though appending, as Section 12 noted, already moves an element without an explicit remove step. Explicit removal is mainly for deletion, where the element should disappear and stay gone, at least until the data changes again and a re-render recreates it. When a deletion does not appear to work, check that you removed the right element: removing a child span when you meant to remove its card leaves the card visible with a missing piece, which looks like a partial deletion.
const note = document.querySelector(".note");
note.remove();
const list = document.querySelector("ul");
const firstItem = list.firstElementChild;
if (firstItem) {
firstItem.remove();
}
const button = document.querySelector("button");
const card = button.closest(".card");
if (card) {
card.remove();
}
Check: What happens to an element after you call remove() on it?
Section 15 — Replacing an element
replaceWith() swaps an element for a new node, while replaceChild(newNode, oldNode) does the same from the parent's side. Replacing is useful when an item's whole structure changes, not just its text: for example, a paragraph that should become a heading, a static display that should become an editable input, or a placeholder that should become a fully built card.
replaceWith() is the modern, element-side form: you call it on the element to be replaced, passing the new node or nodes, and optionally several nodes or strings, which will all take the old element's place in order. replaceChild() is the older, parent-side form: you call it on the parent, passing the new node first and the old child second, and it returns the old child, which can be useful if you need to keep it for undo or for moving elsewhere. As with removal, the modern form is shorter when you already hold the element to replace, while the parent form is what you will see in older code and what you need when you only hold the parent and a reference to the child. Knowing both forms lets you read either style, and choosing the modern form for new code keeps it concise.
Replacing, like removing, detaches the old element, which then exists only if a variable holds it, and can be reinserted elsewhere if needed. Its event listeners do not transfer to the new element, which is an important detail: if the old element had listeners attached with addEventListener(), the new element starts without them, and you must attach the needed listeners to the new element, or use event delegation, covered in the next chapter, so that listeners live on a stable parent and survive child replacements. That listener loss is a common cause of "the new item's button does nothing" bugs after a replacement, and the fix is either to reattach listeners to the new element as part of building it, or to delegate from a parent that is not replaced. Planning for listeners at replacement time, rather than discovering their absence later, is part of writing robust replacement code.
When is replacing better than just changing text or classes? The test is whether the element's type or structure needs to change, or whether the new content is so different that editing the old element would be more code than building a new one. Changing a heading's text is an edit; turning a heading into a form is a replacement. Changing a card's highlight class is an edit; swapping a loading placeholder for a finished card is a replacement. That test — edit for same structure, replace for new structure — keeps code clear, because a reader can see from the method choice whether the change is a small update or a structural swap. Practise by replacing a paragraph with a heading of the same text, and then by replacing it back, watching the tree in developer tools to see the tag change while the position stays the same.
const oldItem = document.querySelector("li");
const newItem = document.createElement("li");
newItem.textContent = "Replacement task";
oldItem.replaceWith(newItem);
const parent = document.querySelector("ul");
const replacement = document.createElement("li");
replacement.textContent = "Another replacement";
parent.replaceChild(replacement, parent.firstElementChild);
Check: When is replacing an element better than just changing its text?
Section 16 — Cloning an element
cloneNode(true) makes a deep copy of an element, including its children; cloneNode(false) copies only the element itself, with its attributes but without its children. The argument is a boolean that answers "deep?", and the default, when you omit it, is false, which is a trap for beginners who expect a full copy and get only an empty shell. Always write the argument explicitly, even when you want a shallow copy, because the explicit true or false documents the intent and prevents the default from surprising a reader. A deep clone is a new element tree, detached like a newly created element, sharing no nodes with the original, so changing the clone's children does not affect the original's children, though attributes are copied as values, not shared, which is the expected behaviour for a copy.
Cloning is useful when you have a complex structure to repeat, such as a card with several children, and building it from scratch with createElement() each time would be repetitive. Cloning a hidden template card, or the first card in a list, gives you a ready-made structure to fill with new data, changing only the text and attributes that differ, which is shorter and less error-prone than recreating every child. If you clone an element with an ID and insert the clone without changing its ID, the page will have two elements with the same ID, which breaks getElementById() and violates HTML validity, so changing or removing duplicated IDs after cloning is an essential step.
Cloned elements do not copy event listeners added with addEventListener(). That is a deliberate design choice, because listeners often close over data specific to the original element, and copying them blindly could cause confusing behaviour, but it means a cloned button will not respond to clicks until you attach its listeners, or until you use event delegation from a stable parent, as the next chapter explains. The practical rule is: after cloning, treat the clone as a fresh element that needs its data updated, its IDs fixed, and its listeners attached, even though its structure arrived ready-made.
A related use of cloning is as a safe way to experiment. You can clone a subtree, modify the clone off-page, and then replace the original with the clone only when the modification is complete, which avoids showing intermediate states to the visitor. That build-off-page-then-swap pattern is a more general version of the fragment pattern in Section 24, and it is useful for complex updates where a partial update would look broken. For ordinary list building, direct creation is simpler, and cloning is best reserved for genuinely repeated complex structures, where the savings in code outweigh the need to fix IDs and listeners. Practise by cloning a card, changing its heading text and removing its ID, and inserting it, then checking in developer tools that the original and the clone are independent.
const card = document.querySelector(".card");
const copy = card.cloneNode(true);
copy.removeAttribute("id");
copy.querySelector("h2").textContent = "Cloned card";
document.body.appendChild(copy);
const shallow = card.cloneNode(false);
console.log(shallow.children.length); // 0 — children not copied
Check: What is the difference between cloneNode(true) and cloneNode(false)?
Section 17 — Walking the tree: parents and children
Every element knows its neighbours: parentElement goes up one level, children lists only element children, and childNodes lists every node including text and comments. These properties let you move from a starting element to related elements without searching the whole document, which is useful when the relationship is the point: a button's card, a list item's list, or a heading's section. parentElement returns the parent element or null for the root, and chaining it walks up the tree level by level, as in button.parentElement.parentElement, though chaining many levels is fragile if the structure changes, and closest() is often a better tool for finding an ancestor by selector, as Section 14 noted. For a single, stable step up, parentElement is direct and clear.
children and childNodes differ in what they count, and that difference explains many off-by-one surprises in tree walking. children is an HTMLCollection of element children only, ignoring text nodes, including the whitespace text between elements in formatted HTML. childNodes is a NodeList of all child nodes, including those whitespace text nodes, comments, and element nodes. In HTML written with line breaks and indentation between tags, childNodes will contain text nodes for that whitespace, so its length is larger than children's length, and indexing into it by position will land on text nodes where you expected elements. That is why the "Element" versions of tree properties — children, firstElementChild, nextElementSibling — are usually preferred: they skip the whitespace noise and match the elements a developer thinks of as the children. The non-Element versions are for the rare cases where text nodes themselves matter, such as processing mixed content in an editor.
Both children and childNodes are live collections, meaning they update automatically when the tree changes, unlike the static NodeList from querySelectorAll(). That liveness is convenient for checking a current count, as in list.children.length for the number of items, but it can surprise you in a loop that adds or removes children, because the collection changes as you loop, shifting indexes and potentially skipping items or looping forever. The safe habit for such loops is to convert to a static array first, with [...parent.children], or to loop backwards when removing, so that index shifts do not affect items you have not yet visited.
Walking the tree is a skill that complements searching. Searching with a selector finds an element by its description, regardless of where it is, while walking finds an element by its relationship to a known element, regardless of its description. A delete button does not need to know its card's class if it can walk up to its parent card, and a list does not need to know its items' classes if it can read its children. Using relationships when they are stable, and selectors when they are not, gives you two complementary ways to navigate, and the best code uses whichever is clearer for the specific move.
const button = document.querySelector("button");
console.log(button.parentElement);
console.log(button.parentElement.children.length);
console.log(button.parentElement.childNodes.length);
const list = document.querySelector("ul");
console.log(list.children[0]); // first element child
console.log([...list.children].map(function (li) { return li.textContent; }));
Check: Why might children.length differ from childNodes.length for the same element?
Section 18 — Siblings and first and last children
nextElementSibling and previousElementSibling move sideways between elements, while firstElementChild and lastElementChild pick the ends of a parent's element children. The "Element" versions skip text nodes, which is usually what you want, for the whitespace reasons explained in the previous section. A sibling is an element that shares the same parent, so moving sideways stays at the same level of the tree: from one list item to the next, from a heading to the paragraph that follows it, or from a tab to the next tab. That sideways movement is useful for features that depend on order or adjacency, such as "next" and "previous" navigation, highlighting the item after the current one, or finding the content panel that follows a tab button in the markup.
firstElementChild and lastElementChild are shortcuts for the ends of a parent's children, equivalent to children[0] and children[children.length - 1], but clearer in intent and safe when the parent has no children, in which case they return null rather than undefined from an out-of-range index. That null return is consistent with other DOM finders, and it should be guarded before use, just as a querySelector() result should be. Similarly, nextElementSibling returns null when the element is the last child, and previousElementSibling returns null when it is the first, which are natural stopping conditions for loops that walk sideways until they run out of siblings. A loop that moves with nextElementSibling until it hits null visits every following sibling in order, which is a clean pattern for processing a sequence starting from a known element, without needing to find the parent or index into its children.
The non-Element siblings, nextSibling and previousSibling, and the non-Element ends, firstChild and lastChild, include text nodes, so they can land on whitespace text between elements in formatted HTML, which is rarely the intended target. That is why the Element versions are the default recommendation in this book, and why you should only use the non-Element versions when you deliberately need text nodes, such as in an editor that manipulates text directly. When a sideways move gives you a text node or null unexpectedly, check which version you used: the fix is usually to switch to the Element version, which skips the whitespace and lands on the next real element.
Sideways movement combines well with parent movement for relative navigation. From a button, button.parentElement finds its card, and from that card, nextElementSibling finds the next card, which is a two-step walk that needs no IDs or classes for the cards themselves. That relative style is robust to redesigns that keep the structure but change the class names, and it is concise for features that are inherently relative, like moving an item up or down in a list by swapping it with its sibling. For swapping, the DOM offers no single "swap" method, but a combination of insertBefore() and sibling references achieves it, and understanding siblings is the prerequisite for writing that combination correctly.
const first = document.querySelector("li");
console.log(first.nextElementSibling);
console.log(first.parentElement.lastElementChild);
console.log(first.previousElementSibling); // null — first has no previous
let current = first;
while (current) {
console.log(current.textContent);
current = current.nextElementSibling;
}
Check: Why is nextElementSibling usually more useful than nextSibling?
Section 19 — Data attributes with dataset
Custom data can live on an element as data- attributes, such as data-user-id="42", and JavaScript reads them through dataset, converting hyphens to camelCase: element.dataset.userId. Data attributes are a standard HTML way to attach small pieces of data to an element, for use by JavaScript, without inventing non-standard attributes or hiding data in class names or text. They are visible in the HTML, they are valid, and they are ignored by browsers for rendering, so they do not affect layout or styling unless you deliberately use them in CSS, which can also select by data attributes. That combination — valid, visible, inert — makes them the right place for per-element data like an item's id, a product's price, or a tab's target panel.
The hyphen-to-camelCase conversion is the detail to learn. In HTML, the attribute is written with hyphens, as data-user-id, because HTML attribute names are hyphenated by convention. In JavaScript, dataset properties cannot contain hyphens, because a hyphen would be read as a minus sign, so the name is converted: remove the data- prefix, then convert each hyphenated part to camelCase, so data-user-id becomes dataset.userId, and data-product-price becomes dataset.productPrice. Writing dataset.user-id in JavaScript is a syntax error, or rather, it is parsed as dataset.user minus id, which is not what you meant, so the camelCase conversion is not optional. Conversely, setting dataset.userId = "99" in JavaScript creates or updates the data-user-id attribute in the HTML, which you can see in developer tools, confirming the two-way conversion.
All dataset values are strings, even when they look like numbers or booleans, because attributes are always strings in HTML. data-user-id="42" gives dataset.userId as the string "42", not the number 42, so comparing it to a number with === will fail, and doing maths on it without converting it first will concatenate rather than add, or give NaN in some operations. The fix is to convert explicitly when you need a number, with Number(dataset.userId), or to compare as strings consistently.
Data attributes are particularly useful with event delegation, covered in the next chapter, where a single listener on a parent needs to know which child was clicked and what data that child represents. Storing the child's id in a data- attribute lets the listener read it from the clicked element, without searching elsewhere for the data, which keeps the handler simple and self-contained. They are also useful for configuration that belongs to an element, such as a tooltip's text or a panel's target, where the data is naturally attached to the element it describes. For larger or more complex data, such as a whole object, data attributes are not the right tool, because they only hold strings, and serialising an object into a string attribute is fragile; in those cases, keep the data in a JavaScript variable and link it to the element by id, rather than stuffing it into an attribute.
<button data-user-id="42">View</button>
const button = document.querySelector("button");
console.log(button.dataset.userId); // "42" — a string
console.log(Number(button.dataset.userId)); // 42 — converted
button.dataset.userId = "99";
console.log(button.getAttribute("data-user-id")); // "99"
Check: What does data-user-id become in dataset?
Section 20 — Reading and setting form values
Form controls hold their current value in the value property: reading it tells you what the user typed or chose, and assigning to it changes what is shown. This is different from the value attribute in the HTML, which is only the starting value, as Section 47 explains in detail. When a page loads, the property is initialised from the attribute, but after that, the two diverge: the user typing changes the property, not the attribute, and your script changing the property changes what is displayed, not the original HTML. That divergence is why form handling always uses the property, not the attribute, for the current value, and why resetting a form restores the attribute values, not the values the user typed after loading.
Different controls expose their state through different properties, and learning the set is part of form handling. Text inputs, textareas, and select menus use value for their current text or chosen option value. Checkboxes and radio buttons use checked, a boolean, for whether they are ticked or selected, covered in Section 46, while their value property holds the value that would be submitted if they are checked, which is a different thing. A select menu's value gives the value of the currently selected option, and setting it selects the option with that value, if one exists. For a multi-select, the selectedOptions collection gives all selected options. Knowing which property holds the state for each control type prevents the mistake of reading value from a checkbox and expecting true or false, when you will actually get its submission value regardless of whether it is ticked.
Setting a value programmatically is useful for prefilling forms, for clearing a form after submission, and for updating one field based on another, such as filling a city field when a postcode is chosen. Assigning to value updates the display immediately, but it does not fire the input or change events that a user typing would fire, which means any code listening for those events will not run unless you dispatch the event manually, a detail that matters in the events chapter. When you need those handlers to run after a programmatic set, dispatching the event explicitly, or calling the handler function directly, is the fix.
Reading form values is the first step in handling a submission, whether the submission is a traditional form post or a JavaScript-handled action that reads the values, validates them, and sends them via an API. The pattern is: find each control, read its value or checked property, trim whitespace from text inputs where appropriate, validate the values, and then use them. Trimming is a small but important step, because users often add accidental spaces at the start or end of an input, and a name with a trailing space is not the same string as the name without it, which can cause failed lookups or duplicate entries.
const input = document.querySelector("#name");
console.log(input.value);
input.value = "Ada";
console.log(input.value); // "Ada"
console.log(input.getAttribute("value")); // original starting value, if any
const select = document.querySelector("select");
console.log(select.value);
Check: What is the difference between an input's value property and its value attribute?
Section 21 — Building a list from an array
A common pattern is: take an array of data, loop over it, create an element for each item, and append each one to a list. The data stays in JavaScript; the page is just a view of it. That separation, data in variables and display in the DOM, is the central idea of this chapter's second half, because it means the list on the page is never the source of truth: the array is. If the data changes, you rebuild the view from the array, rather than trying to edit the page's text and read it back later.
The implementation follows the steps you already know, combined in order. First, find the container element, usually a ul or a div, with querySelector(). Then, for each item in the array, create the appropriate element with createElement(), set its textContent from the item's data — or from a property of the item, if the items are objects — and append it to the container, directly or via a fragment for long lists. That loop is the render step, and it can be wrapped in a function, as Section 45 shows, so that any data change can trigger a fresh render by calling the function again. The function's job is purely to turn data into elements, with no data logic of its own, which keeps it simple and testable: given the same array, it should always produce the same list.
When the items are objects, the element-building step becomes slightly richer, because each item contributes several values to its element. A product object might contribute its name to a heading, its price to a paragraph, and its id to a data- attribute, all within one card element built per product. Building that card involves creating the card, creating its children, filling each child from a property, and appending the children to the card before appending the card to the container. The key is to build the whole card detached, then append it once, so the page never shows a half-built card.
A mistake to avoid is building list items from strings with innerHTML in a loop, especially when the data comes from users or from a server you do not fully control. That approach is shorter to write, but it reintroduces the injection risk of Section 23, and it also makes the code harder to extend, because adding a class, an attribute, or a listener to an item requires editing a string template rather than calling a method on an element. The createElement() plus textContent approach is slightly longer, but it is safe by construction, and each piece of the element is set explicitly, which makes the code easier to read and to change.
const fruits = ["apple", "banana"];
const list = document.querySelector("ul");
for (const fruit of fruits) {
const item = document.createElement("li");
item.textContent = fruit;
list.appendChild(item);
}
const products = [{ name: "Pen", price: 500 }];
const container = document.querySelector("#products");
for (const product of products) {
const card = document.createElement("article");
card.textContent = product.name + " — " + product.price;
container.appendChild(card);
}
Check: In this pattern, where does the data live — in the HTML or in the JavaScript array?
Section 22 — Clearing a container before re-rendering
Before drawing a fresh list, empty the container, for example with container.textContent = "" or container.replaceChildren(). Without this step, each re-render adds duplicates on top of the old list, because appending does not replace existing children, it adds to them. That duplication bug is one of the most visible DOM bugs, because the page literally shows each item twice, then three times, then more, growing with every update, and it is also one of the easiest to fix, once you recognise it: clear first, then render.
replaceChildren() is the modern, explicit way to clear and optionally replace children in one call. Calling it with no arguments removes all children, leaving an empty container ready for a fresh render. Calling it with new nodes replaces the old children with the new ones in a single step, which combines clearing and rendering, though for the render pattern in this chapter, clearing first and then appending in a loop is clearer, because the loop is the render step and the clear is a separate, visible preparation. Setting textContent = "" achieves the same clearing by replacing all children with empty text, which is a concise trick, but it is less explicit about intent than replaceChildren(), and a reader might mistake it for a text update rather than a container clear. Either method works, and the choice is stylistic, but replaceChildren() states the job more clearly.
Clearing has a side effect to be aware of: it removes any event listeners attached to the old children, because those children are detached and, unless referenced elsewhere, become eligible for garbage collection. That is usually fine in a re-render, because the new children will get fresh listeners as they are built, or because listeners are delegated from the container, which itself is not cleared, only its children are. Delegation, covered in the next chapter, is particularly compatible with re-rendering, because the listener lives on the stable container and survives any number of child replacements, while direct listeners on children must be reattached on every render. When a re-rendered list's buttons stop working, the usual cause is direct listeners that were lost in the clear, and the fix is either to reattach them during rendering or to delegate from the container.
The render pattern, with clearing as its first step, is therefore: clear the container, loop over the data, build elements, append them. That four-step pattern, wrapped in a function, is the reusable core of list display, and it appears in Section 45 as a complete function. Practise it by building a list, rendering it twice from the same data, and confirming that the second render does not duplicate the items. Then change the data — add an item, remove one — and re-render, confirming that the display matches the new data exactly, with no leftovers from the old list.
const list = document.querySelector("ul");
list.replaceChildren();
const fruits = ["apple", "banana"];
for (const fruit of fruits) {
const item = document.createElement("li");
item.textContent = fruit;
list.appendChild(item);
}
list.textContent = ""; // another way to clear, before re-rendering
Check: What goes wrong if you re-render a list without clearing it first?
Section 23 — The innerHTML trap
Setting innerHTML with text that came from a user can inject real HTML or scripts into your page — a cross-site scripting (XSS) risk. The mechanism is simple: innerHTML parses its string as markup, so a string containing a tag becomes a real element, and a string containing a script element, or an element with an event handler attribute, can execute code in the visitor's browser, in the context of your page. That code can then read page data, impersonate the visitor, or redirect them, which is why XSS is treated as a serious security issue, not just a display bug. The attacker does not need access to your server or your code; they only need a place where user input is displayed through innerHTML, such as a comment, a username, a search term echoed back, or a profile field, and the browser does the rest, faithfully parsing the attacker's markup as if it were your own.
The defence is equally simple, and it is the rule from Sections 6 and 7: display user text with textContent and build elements with createElement(), never with innerHTML containing untrusted data. That rule is safe by construction, because textContent never parses its input, so no string, however crafted, can become markup through it. For learning projects, the safe rule is stricter and simpler: do not use innerHTML with any outside data at all, and you will not have an XSS bug from this cause.
There are legitimate uses of innerHTML, and banning it entirely would be an overreaction. Markup you wrote yourself, in your own code, with no interpolated variables, is safe, because you know exactly what it contains. A static template string for a layout you designed, with no user data in it, is safe to insert with innerHTML, though building it with elements is also fine and often clearer. The danger is interpolation: a template like ` <p>${userComment}</p> looks harmless, but the interpolated part is untrusted, and the whole string is then parsed, so the untrusted part gains markup powers it should not have. That interpolation pattern is the trap's most common form, because it is convenient and it works correctly for innocent input, failing only when the input is malicious, which is exactly when safety matters most. If you remember one sentence from this section, make it this: never interpolate untrusted data into an innerHTML` string.
Testing for this trap is straightforward. Try displaying a comment containing <b>bold</b> and a comment containing an image tag with an error handler, through your display code, and see whether the markup becomes real or stays as text. With textContent, both stay as text, which is the safe result, even though the bold comment will not actually be bold, which is a trade-off: safe display means users cannot use markup in their comments, unless you deliberately support a safe subset through a sanitiser.
const comment = document.querySelector("#comment");
// Safe: shows the text literally, even if it contains tags
comment.textContent = "<b>Not bold, just text</b>";
// Dangerous — do NOT do this with user input:
// comment.innerHTML = userComment;
const trusted = document.querySelector("#trusted");
trusted.innerHTML = "<strong>Markup I wrote myself</strong>";
Check: Why is element.innerHTML = userComment dangerous?
Section 24 — Building many elements efficiently
Appending one element at a time can be slow for long lists, because the browser may rework the layout after each addition. Each insertion into a live page can trigger a recalculation of sizes and positions, known as layout or reflow, and for a list of a few items that cost is invisible, but for hundreds or thousands of items it adds up, making the page feel sluggish during rendering. That is why efficient list building assembles nodes off-page and inserts them together.
A DocumentFragment is the tool for that assembly. It is a lightweight, invisible container that holds nodes without being part of the page, so appending to it does not trigger layout, and appending the fragment itself to the page moves all its children into the page in a single step, leaving the fragment empty. The pattern is: create a fragment, loop over the data, create each element, append it to the fragment, and after the loop, append the fragment to the container. From the visitor's perspective, the list appears all at once, fully built, rather than growing item by item, which is also a nicer visual experience, without flicker or partial states. The fragment itself never appears in the page's tree, which can be confusing when you inspect the result, because you will see the container's new children but no fragment element, and that is correct: the fragment is a delivery vehicle, not a part of the page.
An alternative that achieves a similar result is to build a detached container element, fill it, and then append it, or to hide the container, fill it, and show it again, as Section 42 discusses. The fragment is the cleanest of these options for list building, because it does not introduce an extra wrapper element that might affect styling or semantics, and it does not require toggling visibility, which can cause its own layout work. For short lists, the difference between fragment building and direct appending is negligible, and direct appending is simpler to read, so you should not feel obliged to use fragments everywhere. Use them when the list is long, when the rendering happens repeatedly, or when you have measured a performance problem, and use direct appending for short, one-off lists where clarity matters more than speed.
Practise the fragment pattern on a list of, say, one hundred items, built both ways, and notice that the result on the page is identical, only the building method differs. That identical result is the point: fragments change how you build, not what you build, so they can be adopted without changing the display logic. The loop body stays the same — create an element, set its text, append it — only the append target changes, from the container to the fragment, with one final append of the fragment to the container.
const fragment = document.createDocumentFragment();
for (const fruit of ["apple", "banana"]) {
const item = document.createElement("li");
item.textContent = fruit;
fragment.appendChild(item);
}
document.querySelector("ul").appendChild(fragment);
console.log(fragment.childNodes.length); // 0 — children moved into the list
Check: What advantage does a DocumentFragment give when building a long list?
Section 25 — Reusing markup with template
A <template> element holds HTML that the browser does not render until you clone it. It is a tidy way to repeat a card or row layout without writing the same markup in JavaScript strings. The template's content lives in a separate document fragment, inert: images inside it are not loaded, scripts inside it are not run, and its elements are not part of the page's tree, so they do not affect layout, styling, or accessibility until they are cloned and inserted. That inertness is what makes templates safe and efficient for repeated structures, because the markup is written once, in HTML where it belongs, and reused many times, without being parsed as a string or built element by element in JavaScript.
Using a template follows a fixed pattern. First, write the template in HTML, with placeholders where data will go, such as an empty heading and an empty paragraph inside a card article. Then, in JavaScript, find the template by its ID, access its content property, which is the fragment holding the template's markup, and clone that content deeply with cloneNode(true). Next, find the elements inside the clone — not inside the template itself, because changing the template would change every future clone — and fill them with data, using textContent and setAttribute() safely. Finally, append the filled clone to the container, where it becomes live, visible, and interactive.
Templates have several advantages over building elements purely in JavaScript or over innerHTML strings. Compared to createElement() building, templates keep markup in HTML, where it is easier to write, to style, and to review, especially for complex cards with many nested elements. Compared to innerHTML strings, templates avoid parsing user data as markup, because data is filled through textContent after cloning, so the injection risk is removed while the convenience of a written layout is kept. Compared to cloning a visible element, templates avoid the duplicated-ID problem, because the template itself is not on the page and its content has no live IDs to duplicate, though you must still ensure that cloned content does not introduce duplicate IDs if the template contains them, by changing or removing them after cloning, as Section 16 warned.
A template can also hold more than one top-level element, and its content can include any HTML, including forms, tables, and interactive elements, though listeners must still be attached after cloning, because cloning does not copy addEventListener() listeners, as Section 16 explained. That listener step is part of the filling stage: after cloning and filling with data, attach any needed listeners to the clone's buttons or inputs, or rely on delegation from the container, which is often cleaner for repeated items. Practise by writing a card template in HTML, cloning it twice with different data, and inserting both clones, then inspecting the page to confirm that the template itself never appears as a visible card, only its clones do.
<template id="card-template">
<article class="card"><h2></h2></article>
</template>
const template = document.getElementById("card-template");
const card = template.content.cloneNode(true);
card.querySelector("h2").textContent = "Hello";
document.body.appendChild(card);
console.log(document.querySelectorAll(".card").length);
Check: Why does content inside a template not show on the page by itself?
Section 26 — Reading computed styles
Inline style only shows styles set directly on the element. To find the style the browser actually applied, including from stylesheets, use getComputedStyle(element), which returns the final values after the cascade, inheritance, and default styles have all been resolved. The returned object is read-only and holds a value for every CSS property, even properties you never set, because the browser computes a value for each one, from a stylesheet, from inheritance, from a default, or from the element's own inline style. That completeness makes computed styles useful for debugging — "why is this element actually blue?" — and for reading a value that JavaScript needs to act on, such as the current width of an element that was sized by CSS, but it also means the returned object is large, and you should read only the specific property you need, as in getComputedStyle(box).color.
The values returned are resolved values, which may differ in form from what was written in CSS. A colour written as a name or a hex code in a stylesheet will be returned as an rgb() string, and a size written as em or as a percentage may be returned as a pixel value, because the browser has resolved it against the element's context. That resolution is helpful when you need the actual, rendered value, but it means you cannot recover the original CSS text from a computed style, and you should not compare a computed value to a stylesheet value written in a different form and expect them to match as strings.
Computed styles are also expensive to read in a loop, because asking for a computed value can force the browser to perform layout synchronously, to ensure the value is up to date. That forced layout, repeated many times, is a performance trap known as layout thrashing, covered in Section 42, and it is why you should avoid reading computed styles inside a loop that also writes styles, or reading them repeatedly for many elements when a single read, or a class check, would do. For ordinary, one-off reads — debugging, or reading a single element's size before an animation — the cost is negligible, and getComputedStyle() is the right tool. The caution is only about bulk or repeated reads in performance-sensitive code, where batching reads before writes, or avoiding computed reads altogether, keeps the page smooth.
A final distinction is between computed style and the element's style property for writing. You cannot assign to a computed style object to change an element's appearance; it is read-only, and attempting to set a property on it has no effect. To change appearance, assign to element.style, toggle a class, or change a stylesheet rule, and then, if needed, read the new computed style afterwards to confirm the change. That read-after-write pattern is useful in debugging: change a class, then log the computed colour, and confirm that the class change had the intended visual effect.
const box = document.querySelector(".box");
console.log(getComputedStyle(box).color);
console.log(box.style.color); // "" if colour came from a stylesheet
box.classList.add("highlight");
console.log(getComputedStyle(box).color); // new computed colour
Check: Why does box.style.color return an empty string when a stylesheet sets the colour?
Section 27 — Changing many elements at once
querySelectorAll() plus a loop is how you update a whole group: every matching element gets the same change. The pattern is always the same — query for the group, loop over the result, apply the change inside the loop — and it is the DOM equivalent of looping over an array of data to transform each item. Remember the result is a NodeList, so loop with forEach() or for...of, not map(), unless you have converted it to an array first, as Section 5 explained. Inside the loop, the variable holds one element at a time, and any element operation works on it: setting text, toggling a class, changing an attribute, or reading a value. That uniformity means a group update is no harder to write than a single-element update, except for the loop wrapper, which is a small, consistent addition.
Group updates are where classes shine, because a single class change can trigger a whole visual change defined in CSS, for many elements at once, without JavaScript needing to set individual styles. Adding a class like updated to every price element, for example, might change their colour, weight, and background together, as defined in the stylesheet, while the JavaScript only says "these are updated", not how updated looks. That separation keeps the JavaScript short and the styling in CSS, where it belongs, and it makes the group update easy to change later: editing the stylesheet changes the appearance for all elements, without touching the JavaScript. When a group update involves setting the same inline style on many elements, consider whether a class would be better, because a class is a single point of change, while inline styles are scattered across elements and harder to undo.
Filtering a group before updating it is also common. You might want to update only the elements that are currently visible, or only those that pass a condition, such as prices above a threshold. That filtering can be done inside the loop with an if, or by converting the NodeList to an array and using filter() first, then looping over the filtered array. The array route is clearer when the condition is complex, because it separates "which elements?" from "what change?", into two steps that can each be inspected. The in-loop if route is shorter for a simple condition, and it avoids the conversion step.
When a group update does not seem to work on all elements, check the usual suspects in order: did the query actually match all the intended elements, which you can verify by logging the NodeList's length; did the loop run for each one, which you can verify by logging inside the loop; and did the change inside the loop actually apply, which you can verify by inspecting one element afterwards in developer tools? That three-step check — query, loop, change — isolates the failure to one stage, instead of leaving you guessing whether the selector, the loop, or the operation was wrong.
document.querySelectorAll(".price").forEach(function (el) {
el.classList.add("updated");
});
const cards = [...document.querySelectorAll(".card")];
const visible = cards.filter(function (card) {
return !card.hidden;
});
visible.forEach(function (card) {
card.classList.add("shown");
});
Check: How do you add a class to every element with class "price"?
Section 28 — Waiting for the DOM to be ready
If a script runs before the HTML below it has been parsed, querySelector() will find nothing. That timing problem is one of the most common beginner errors, because it produces a null result and a subsequent error, with no obvious link to timing: the code looks correct, the element exists in the HTML, yet the script cannot find it, simply because the script ran too early. The browser parses HTML from top to bottom, executing scripts as it meets them, unless told otherwise, so a script in the head runs before the body has been parsed, and a script in the middle of the body runs before the elements below it have been parsed. The elements above the script are available, the elements below are not, and that top-to-bottom rule explains why the same code works when moved to the end of the body and fails when placed in the head.
There are three standard solutions, and you should know all three because you will meet them in different codebases. The simplest is to put scripts at the end of the body, just before the closing </body> tag, so that by the time the script runs, all the HTML above it has been parsed. The second is to use the defer attribute on an external script tag in the head, as in <script src="app.js" defer></script>, which tells the browser to download the script in parallel but to delay executing it until the HTML has been fully parsed, preserving the order of deferred scripts. The third is to wait for the DOMContentLoaded event, which fires when the HTML has been parsed and the DOM is ready, without waiting for images, stylesheets, and other resources to finish loading, by wrapping your initialisation code in a listener for that event.
DOMContentLoaded should not be confused with the load event, covered in the events chapter, which fires later, when all resources, including images, have finished loading. For DOM manipulation, DOMContentLoaded is usually the right event, because you do not need images to be loaded to find and change elements, and waiting for them would delay your script unnecessarily. That double-check, using document.readyState, is a pattern for library code that must work in any placement, but for your own pages, choosing one placement strategy and sticking to it is simpler and clearer.
When a script fails with a null error on its first DOM line, timing should be your first suspicion, before selector spelling or element existence. Check where the script tag is, check whether it is deferred, and check whether the code waits for DOMContentLoaded. Moving the script to the end of the body is the quickest test: if the code then works, timing was the cause, and you can choose a permanent solution from the three options above. That diagnostic — move the script, see if it works — is faster than re-reading the code for a bug that is not there, because the code was correct, only its timing was wrong.
document.addEventListener("DOMContentLoaded", function () {
console.log(document.querySelector("h1").textContent);
});
console.log(document.readyState); // "loading", "interactive", or "complete"
Check: Name two ways to make sure your script runs after the HTML is parsed.
Section 29 — Searching inside one element
querySelector() and querySelectorAll() also work on an element, not just on document, and then they search only inside that element. This scoped search is how you find the heading inside one particular card, rather than the first heading on the whole page, and it is essential for working with repeated structures, where many cards each have a heading, a price, and a button, and you need the ones belonging to a specific card. The syntax is the same as a document search, except the receiver is the element: card.querySelector("h2") searches the card's subtree, while document.querySelector("h2") searches the whole page. That one-word difference in the receiver changes the scope completely, and it is the key to writing code that works per-item rather than globally.
Scoped searching is the natural partner of loops over groups. A common pattern is: query for all cards, loop over them, and inside the loop, use scoped queries to find each card's parts and update them from that card's data. Without scoping, the loop would repeatedly find the first card's parts, because a document search always starts from the top, and every card would end up showing the first card's data. With scoping, each iteration works on its own card's subtree, and the data stays correctly paired with its display. That pairing — loop variable as scope, scoped queries for parts — is a pattern you will use constantly for card grids, table rows, and list items, and it is worth practising until it feels automatic, because it is the difference between code that works for one item and code that works for many.
Scoping also improves performance and clarity in a subtler way. Searching a small subtree is faster than searching the whole document, though the difference is negligible for small pages, and more importantly, a scoped query documents intent: card.querySelector("h2") says "the heading of this card", while document.querySelector("article.card:nth-child(3) h2") says the same thing in a fragile, position-dependent way. The scoped form survives reordering, insertion, and removal of other cards, because it does not depend on the card's position in the page, only on its own subtree. That robustness is why scoped searching is preferred over complex global selectors for per-item work, even when a global selector could technically find the same element.
A related point is that the element itself is not included in its own scoped search: card.querySelector(".card") will not return the card itself, only descendants of the card that match. If you need to test the element itself, use matches(), which checks whether an element matches a selector, as in card.matches(".selected"), which is useful for filtering a group by a condition on the elements themselves. That distinction — scoped query searches descendants, matches() tests the element — completes the scoped-search toolkit, and it prevents the confusion of a scoped query that returns null when the element itself matches but none of its descendants do.
const card = document.querySelector(".card");
const heading = card.querySelector("h2");
console.log(heading.textContent);
document.querySelectorAll(".card").forEach(function (oneCard) {
const price = oneCard.querySelector(".price");
console.log(price.textContent);
});
console.log(card.matches(".card")); // true
Check: How do you find the h2 inside a specific card, not the first h2 on the page?
Section 30 — Nodes versus elements
A node is the general term for anything in the DOM tree, including text and comments. An element is a specific kind of node: an HTML tag. That is why there are pairs like childNodes (all nodes) and children (elements only), and why some properties have "Element" in their name and others do not. Every element is a node, because every tag in the tree is a node, but not every node is an element, because text, comments, and the document itself are nodes that are not tags. That one-way relationship — elements are a subset of nodes — explains the naming of the DOM API, and it helps you choose the right property: if you want tags, use the Element version; if you want everything, including text, use the general version.
The node types you will meet most are element nodes, text nodes, and comment nodes, plus the document node at the root. A text node holds a piece of text, such as the words inside a paragraph, and it has no tag, no attributes, and no children, though it can have siblings. A comment node holds an HTML comment, which is invisible on the page but present in the tree, and it is usually ignored by code, though it counts in childNodes and in sibling walks that do not filter to elements. The document node is the root of the whole tree, holding the html element as its child, and it is what the document object represents.
Node properties reflect this generality. nodeType is a number identifying the kind of node — 1 for elements, 3 for text, 8 for comments, 9 for the document — and nodeName gives a name like "DIV" for an element or "#text" for a text node. You will rarely need to check nodeType directly in ordinary code, because the Element-specific properties already filter to elements for you, but recognising the numbers helps when you read code that does check them, or when you inspect a node in the console and wonder why its type is not what you expected. The general node methods, like appendChild() and removeChild(), work on any node, which is why they accept text nodes as well as elements, while element-specific conveniences, like classList, exist only on elements, because only elements have classes.
For most front-end work, you can think almost entirely in elements, and use the Element versions of properties to do so, treating text as content accessed through textContent rather than as nodes to walk. That element-centred view is simpler and matches how developers talk about pages: "the card", "the heading", "the list item", all elements. The node view becomes important only when you need to handle text directly, as in an editor, or when you are debugging a tree walk that lands on unexpected text nodes, in which case remembering that text nodes exist, and that they are nodes but not elements, explains the surprise and points to the Element-version fix.
const paragraph = document.querySelector("p");
console.log(paragraph.nodeType); // 1 — element
console.log(paragraph.firstChild.nodeType); // 3 — text, usually
console.log(paragraph.children.length); // element children only
console.log(paragraph.childNodes.length); // all nodes, including text
Check: Is every node an element? Is every element a node?
Section 31 — Live collections versus static lists
getElementsByClassName() returns a live collection that updates automatically when the page changes, while querySelectorAll() returns a static snapshot that does not. A live collection is like a window onto the current tree: add a new element with that class, and the collection's length grows, without any new query; remove an element, and the collection shrinks. A static NodeList is like a photograph taken at query time: it shows the elements that matched then, and later changes to the page do not alter it, until you query again for a fresh photograph.
The danger of a live collection is in loops that change the very collection they are looping over. Suppose you loop over a live collection of cards and remove each card as you visit it. Removing the first card shifts the second card into the first position, but the loop counter has already moved to position 1, so the shifted card is skipped, and the loop ends having removed only half the cards. That skipping bug is a classic live-collection trap, and it appears whenever a loop adds or removes elements that match the collection's criteria. The fixes are to convert the live collection to a static array first, with [...collection], and loop over the array, which does not change as the page changes, or to loop backwards, from the end to the start, so that removals do not affect the indexes of items not yet visited.
Live collections also include getElementsByTagName() and the children and childNodes properties, all of which update automatically, while querySelectorAll() is the main static method you will use. In modern code, querySelectorAll() is generally preferred, precisely because its static behaviour is more predictable: the list you queried is the list you loop over, regardless of what happens to the page during the loop, unless you deliberately re-query. That predictability is why this book teaches querySelectorAll() first and treats the live methods as legacy tools to recognise, rather than as defaults to reach for. When you read older code that uses getElementsByClassName(), check whether it loops and modifies, because that is where a live collection can cause a subtle bug that the original author may not have noticed, especially if the list was short or the modification was rare.
There is one legitimate use for a live collection: when you need a count or a reference that stays current without re-querying, such as a live count of items in a list that is displayed and updated frequently. In that case, holding a live collection and reading its length whenever you need the count gives you the current value for free, without a new query each time. For most purposes, re-querying when you need a fresh list is clearer and safer than relying on liveness, and it makes the code's timing explicit: the list is current as of this query, not as of some earlier query that happens to be live.
const live = document.getElementsByClassName("card");
const snapshot = document.querySelectorAll(".card");
console.log(live.length, snapshot.length);
const newCard = document.createElement("div");
newCard.className = "card";
document.body.appendChild(newCard);
console.log(live.length); // grew by 1 — live
console.log(snapshot.length); // unchanged — static
const safe = [...live]; // convert before looping and removing
Check: If you add a new ".card" element, which of the two collections above changes?
Section 32 — Guarding against null
querySelector() returns null when nothing matches, and calling a method on null throws an error that stops your script. The error message, "Cannot read properties of null", is one of the most common JavaScript errors, and it means exactly what it says: the code tried to read a property, like textContent, or call a method, like classList.add(), on a value that is null, which has no properties at all. Debugging therefore starts at the search, not at the failing line, and logging the search result is the fastest way to confirm which search failed.
Guarding means checking the result before using it, in a way that handles the missing case gracefully. The simplest guard is an if statement: if (heading) { heading.textContent = "Found it"; }, which runs the update only when the element exists, and silently skips it otherwise. That silent skip is appropriate when the element is genuinely optional, such as a panel that only exists on some pages, but it can hide a bug when the element should exist and does not, because the code then does nothing instead of failing loudly, and the missing update may go unnoticed. A louder guard is to log a warning or throw a descriptive error when an expected element is missing, as in if (!heading) { console.warn("Heading not found"); }, which makes the failure visible during development while still preventing a crash. Choosing between silent and loud guards depends on whether absence is expected or is a bug, and making that choice deliberately is part of writing robust code.
Optional chaining offers a concise guard for single property reads and method calls, as in heading?.textContent, which gives undefined instead of throwing when heading is null. That conciseness is useful for reads, but it does not help for assignments or for multi-step updates, where an if guard is clearer, because optional chaining on the left side of an assignment is not allowed in the same way. A related tool is the nullish coalescing operator, ??, for providing a fallback value when a read gives null or undefined, as in heading?.textContent ?? "No heading", which combines a safe read with a default display.
Guarding is especially important for code that runs on multiple pages, or on pages whose content varies, because an element that exists on one page may not exist on another, and a script shared across pages must cope with both. A script that assumes a search box exists will crash on a page without one, breaking unrelated features on that page, because a thrown error stops the rest of the script. Guarding the search-box code, so it only runs when the box exists, keeps the shared script safe across pages, and it is a pattern you will use for any feature that is page-specific but lives in shared code. Practise by writing a script that searches for an element you know does not exist, guarding it with an if, and confirming that the script continues to run afterwards, instead of stopping at the error.
const heading = document.querySelector("h1");
if (heading) {
heading.textContent = "Found it";
}
console.log(document.querySelector(".missing")?.textContent); // undefined, no error
const title = document.querySelector("h1")?.textContent ?? "No title";
console.log(title);
Check: What error do you get from calling textContent on a null result, and how do you avoid it?
Section 33 — Moving focus with focus()
element.focus() moves keyboard focus to an element, such as an input after a form error or a search box when a panel opens. Focus is the browser's notion of which element keyboard input goes to, and which element is highlighted as active, and it is essential for keyboard users and screen-reader users, who navigate by focus rather than by mouse. Moving focus deliberately, at the right moment, helps those users by placing them where the action is: after a search panel opens, focus should go to the search input, so the user can type immediately without hunting for it; after a form error, focus should go to the first invalid field, so the user can correct it without searching the form.
Moving focus without reason, or at the wrong moment, confuses users, because it hijacks their navigation and can disorient screen-reader users, who rely on focus position to understand where they are on the page. Focusing an element on page load, for example, can jump a screen-reader user past introductory content they needed to hear, and focusing an element in response to a hover or a scroll can yank keyboard users away from where they were working. The guideline is: move focus only in response to a user action that implies a new location, such as opening a panel, submitting a form with an error, or activating a control that reveals new content, and never move focus on a timer, on scroll, or on load, unless the page's sole purpose is that input, such as a search homepage where the search box is the clear starting point.
Only certain elements can receive focus by default, mainly interactive elements like links, buttons, inputs, and textareas, plus elements with a tabindex attribute that makes them focusable. Calling focus() on a plain div or paragraph will do nothing unless that element has a tabindex, which is a detail that can make a focus call seem to fail silently. For custom interactive elements, such as a card that acts as a button, adding tabindex="0" makes it focusable in the normal tab order, but such custom controls should usually be real buttons instead, for accessibility, with focus() used on the button itself. When you do need to focus a non-interactive element, such as a heading at the start of newly loaded content, tabindex="-1" makes it focusable programmatically without adding it to the tab order, which is the correct pattern for that case, because it allows the focus move without polluting keyboard navigation.
Focus also has a visible aspect: the focus outline, or focus ring, that browsers draw around the focused element. That outline is essential for keyboard users to see where they are, and it should never be removed with CSS without providing an alternative visible indicator, a point that accessibility guidance stresses repeatedly. When you move focus with JavaScript, the outline will appear on the newly focused element, which is correct and helpful, and it confirms that the focus move worked, because you can see the outline jump to the new location.
document.querySelector("#search").focus();
const errorField = document.querySelector(".error input");
if (errorField) {
errorField.focus();
}
const panelHeading = document.querySelector("#results-heading");
panelHeading.setAttribute("tabindex", "-1");
panelHeading.focus();
Check: When is moving focus helpful, and when is it harmful?
Section 34 — Scrolling an element into view
element.scrollIntoView() scrolls the page so that element becomes visible. It is useful after adding a new item at the bottom of a long list, or when jumping to a section a user asked for, such as clicking a table-of-contents link that should bring a heading into view, or submitting a form that should reveal a results section below the fold. Without scrolling, the user might not notice that new content appeared off-screen, and might think the action did nothing, even though the page updated correctly below the visible area. Scrolling bridges that gap between a data change and a user's attention, by bringing the changed area into view at the moment it changes, which is a small but important part of making an interaction feel responsive.
scrollIntoView() accepts an options object that controls how the scrolling behaves, such as { behavior: "smooth" } for an animated scroll, and block: "start", "center", "end", or "nearest" for where the element should end up in the viewport. Smooth scrolling is often nicer than an instant jump, because it preserves the user's sense of where they are on the page, showing the movement rather than teleporting, but it should respect the user's motion preferences: a user who has set "reduce motion" in their system settings should get an instant scroll, not an animation, and the CSS prefers-reduced-motion media query or a JavaScript check for it can enforce that. The block option matters for context: scrolling an element to the centre keeps surrounding content visible, which can help orientation, while scrolling it to the start puts it at the top, which is familiar for section jumps.
Scrolling should be used sparingly, and only when the user expects a move. Scrolling on every small update, or scrolling in response to a hover, can disorient users, especially screen-reader users, for whom a scroll is not necessarily announced, and who may lose their place in the content. A good test is: did the user ask to see this content, or did the content appear as a side effect of another action? If the user clicked "show results", scrolling to the results is expected. If a background update added a new item to a feed the user was already reading, scrolling to it might yank them away from what they were reading, and a less intrusive indicator, such as a "new items" button, might be better.
There is also a distinction between scrolling the whole page and scrolling a container. scrollIntoView() scrolls all scrollable ancestors as needed to bring the element into view, which usually means the page, but if the element lives inside a scrollable panel, that panel will scroll too, which is usually the desired behaviour. For finer control, such as scrolling a container by a specific amount, the container's scrollTop property or its scrollTo() method can be used directly, but those are less common than scrollIntoView() for the "bring this element into view" job, which is what most features need.
const target = document.querySelector("#results");
target.scrollIntoView();
target.scrollIntoView({ behavior: "smooth", block: "start" });
const item = document.querySelector("li:last-child");
if (item) {
item.scrollIntoView({ behavior: "smooth", block: "nearest" });
}
Check: When would you call scrollIntoView() after adding content to a page?
Section 35 — Hiding and showing elements
The hidden property hides an element, and setting style.display = "none" does the same through CSS. To show it again, set hidden to false or clear the display value. The hidden attribute, reflected as the hidden property, is the semantic way to say "this element is not relevant right now": it is a standard HTML attribute, it is understood by browsers and by assistive technology, and it hides the element without needing a CSS rule, which makes it clear in the HTML itself that the element is intentionally hidden. display: none through CSS achieves the same visual result, and it is the right choice when hiding is part of styling, such as a responsive rule that hides a sidebar on small screens, but it does not carry the same semantic meaning in the HTML, and it can be overridden by a more specific CSS rule, which can cause a hidden element to reappear unexpectedly if a stylesheet changes.
Both methods remove the element from layout and from the accessibility tree, meaning the element takes up no space and is not announced by screen readers, which is correct for content that is truly not relevant at the moment, such as a collapsed panel or a tab that is not selected. That is different from merely making an element invisible with visibility: hidden or with opacity: 0, both of which keep the element's space in the layout, and, in the case of opacity, keep it in the accessibility tree, which can confuse screen-reader users who encounter invisible content. For most show-and-hide features, hidden or display: none is the correct choice.
Prefer toggling a class for show-and-hide behaviour that also needs styling, as Section 37's toggle pattern shows, because a class can change more than just visibility: it can animate the opening, change an icon, or adjust surrounding layout, all defined together in CSS. Toggling hidden directly is simpler for a single, unstyled hide, and it is the clearest code when the only change is visibility. The guideline is: if showing or hiding is the whole change, use hidden; if showing or hiding is part of a larger visual state, use a class that includes the visibility change plus its styling, so that the state is defined in one place in CSS.
When you hide an element that currently holds focus, or that contains the focused element, focus can be lost, leaving keyboard users without a clear position on the page. The remedy is to move focus deliberately to a sensible place when hiding, such as back to the button that opened the panel, or to the next visible element, following the focus guidance in Section 33. Similarly, when you show an element in response to a user action, consider whether focus should move into it, as with a search panel, or whether the user should discover it in the normal tab order, as with a newly added list item.
const panel = document.querySelector(".panel");
panel.hidden = true;
panel.hidden = false;
panel.style.display = "none";
panel.style.display = ""; // clear inline display, let CSS decide
panel.classList.toggle("open");
Check: How do you show an element again after setting hidden to true?
Section 36 — Updating images and links
Changing an image means changing its src (and its alt to match the new picture); changing a link means changing its href and possibly its text. Always update the descriptive text along with the visual change, or screen-reader users get stale information. An image swap in a gallery, for example, should update both the picture and its alt text to describe the new picture, and a link whose destination changes should update its text to describe the new destination, unless the text was already generic enough to cover both. That paired update — visual plus descriptive — is a small habit that makes dynamic content accessible, not just visible.
Image updates also involve loading. Changing src starts a new network request for the new picture, and until it loads, the browser may show the old picture, a broken-image icon, or nothing, depending on the browser and the situation. Preloading the new image, by creating an Image object and setting its src in advance, can make a gallery swap feel instant, because the picture is already in the browser's cache when the visible image's src changes.
Link updates have their own considerations. Changing href changes where a click goes, but it does not change the link's text, its title, or any surrounding description, all of which should be checked for consistency, as Section 8 noted. Changing a link to point to a different site should also prompt a check of whether the link should open in a new tab, whether it needs a rel attribute for security, and whether its text still makes sense out of context, because screen-reader users often navigate by links alone, hearing a list of link texts without surrounding paragraphs. A link text like "Click here" is poor in that context even before a change, and a dynamic link that keeps such text after its destination changes is doubly poor. Writing link text that describes the destination — "Read the full guide", not "Click here" — makes dynamic link updates safer, because the text remains meaningful even if the destination changes within the same topic.
Both image and link updates are examples of the general principle that an element is a bundle of related properties, and changing one property in isolation can leave the bundle inconsistent. That principle applies to form controls, to cards, and to any composite element, and it is why the update code for a gallery item should be written as a small function that updates all the related properties together, rather than as scattered assignments across different parts of the program. A function like showImage(index) that sets the image's src, its alt, a caption's text, and a counter's text, in one place, ensures that no part is forgotten, and it makes the gallery's update logic easy to review.
const img = document.querySelector("img");
img.src = "sunset.jpg";
img.alt = "Sunset over the sea";
const link = document.querySelector("a");
link.href = "https://example.com/guide";
link.textContent = "Read the full guide";
const preload = new Image();
preload.src = "next-photo.jpg";
Check: When you swap an image with JavaScript, what else must you update besides src?
Section 37 — The toggle pattern
A common pattern is a button that toggles a class on another element, and CSS decides what that class looks like — for example, a menu that opens and closes. JavaScript handles the state change; CSS handles the appearance. Toggling a class, by contrast, lets CSS define the open state completely, in one place, with all its visual details, while JavaScript only flips the state, in one line, without knowing or caring what open looks like. That division makes both layers easier to change independently: a designer can restyle the open state without touching JavaScript, and a developer can change the toggle logic without touching CSS.
The toggle pattern usually involves a button and a target element, which may be the button's sibling, its parent's child, or an element elsewhere on the page found by ID. The button's click handler, covered in the next chapter, calls target.classList.toggle("open"), and CSS defines .open with the open appearance, such as display: block for a menu that is display: none by default, or a transform that slides a panel into view. Keeping the button's state in sync with the target's state is part of the pattern's completeness: a button that says "Show" while its panel is already open is misleading, and the fix is to update the button's text or its aria-expanded attribute in the same handler that toggles the target, so the two always agree.
Accessibility is an integral part of the toggle pattern, not an addition to it. A toggle button should have an aria-expanded attribute that reflects whether its target is open, so screen-reader users know the state without seeing it, and that attribute should be updated in the same handler that toggles the class, to keep the announced state in step with the visual state. The target panel, when it is a menu or a disclosure, should also be hidden with hidden or with CSS tied to the class, so that it is not in the accessibility tree when closed, as Section 35 explained. A toggle that only changes colours, without hiding the closed content from assistive technology, leaves hidden content discoverable by screen readers, which is confusing, so the class should control both visibility and appearance together.
The toggle pattern generalises beyond menus. Tabs toggle a selected class on the tab and a visible class on its panel, accordions toggle an open class on a section, theme switches toggle a dark class on the document, and favourite buttons toggle a saved class on an item. In each case, the structure is the same: a user action, a class toggle, and a CSS definition of the toggled state, plus any accessibility attributes that describe that state. Recognising that generality lets you reuse the pattern confidently, rather than inventing a new mechanism for each feature, and it gives you a vocabulary for describing features to other developers: "this is a toggle" tells a colleague the structure immediately, without a long explanation.
const button = document.querySelector("#menu-button");
const menu = document.querySelector("#menu");
button.addEventListener("click", function () {
const isOpen = menu.classList.toggle("open");
button.setAttribute("aria-expanded", String(isOpen));
button.textContent = isOpen ? "Close menu" : "Open menu";
});
Check: In the toggle pattern, which layer decides what "open" looks like — JavaScript or CSS?
Section 38 — Building a table from data
Tables are built the same way as lists: loop over an array of objects, create a row (tr) for each, create a cell (td) for each property, and append them. Keeping the row-building code in one function keeps it readable, because the function takes one data object and returns one row element, with all the cell creation and filling inside it, so the main loop becomes a simple "for each item, build a row, append it" sequence, without the cell details cluttering it. That function-per-row pattern is the table version of the render pattern, and it scales to tables with many columns, because adding a column means adding one cell creation inside the function, not changing the loop.
Tables have a specific structure that must be respected for the browser to render them correctly and for assistive technology to understand them. Rows live inside a tbody element, or directly inside the table if no tbody is written, and header rows live inside a thead, with header cells as th elements, not td. When building a table with JavaScript, you should create or find the tbody and append rows to it, rather than appending rows directly to the table in a way that bypasses the tbody, because the browser may move rows into an implicit tbody anyway, causing confusion about where rows actually live. Header cells deserve attention too: a th with a scope attribute, such as scope="col" for a column header, tells screen readers which cells the header describes, which is essential for understanding a table without seeing its layout.
Cell content should be set with textContent, for the safety reasons that run through this chapter, unless a cell genuinely needs markup, in which case a trusted template or element building is the safe route. Numbers that will be sorted or summed later should be kept as numbers in the data, and formatted as text only for display, for example with a currency format, so that sorting by the displayed text does not produce the string-sorting surprise of Chapter 18. If a table needs sorting, the sort should operate on the data array, followed by a re-render, rather than on the displayed cells, which is the render pattern applied to a sortable table.
Empty tables deserve a deliberate design, as with empty lists. A table with no rows should show a message, either as a row spanning all columns with text like "No results found", or as a separate message element replacing the table, so that the user knows the table is empty rather than broken or still loading. That empty row should be a td with a colspan attribute covering all columns, so it spans the table's width and reads correctly, and it should be removed on the next render when data arrives, by the clearing step of the render pattern.
const row = document.createElement("tr");
const cell = document.createElement("td");
cell.textContent = "Ada";
row.appendChild(cell);
function buildRow(user) {
const tr = document.createElement("tr");
const nameCell = document.createElement("td");
nameCell.textContent = user.name;
const ageCell = document.createElement("td");
ageCell.textContent = String(user.age);
tr.append(nameCell, ageCell);
return tr;
}
Check: What elements do you create for each row and each value in a table?
Section 39 — Keeping data and display separate
Store the real data in variables, and treat the DOM as a display of that data. When data changes, update the variable first, then re-render the display from it. Code that reads numbers back out of page text to do maths is fragile and hard to debug, because page text is formatted for humans, not for calculations: it may include currency symbols, commas, units, or rounding, all of which must be stripped before maths, and any change to the display format breaks the calculation. Data in variables, by contrast, is in its natural type — numbers as numbers, booleans as booleans, dates as dates — and maths on it is straightforward and reliable. That type fidelity is the main reason for the separation: the display is a formatted view, and the data is the unformatted truth, and confusing the two leads to parsing bugs that are avoidable by keeping the truth in variables.
The separation also makes features easier to implement, because a feature becomes a data change plus a re-render, rather than a series of DOM edits that must be kept in sync. Adding a task means pushing to the tasks array and re-rendering the list; removing a task means removing from the array and re-rendering; sorting means sorting the array, or a copy of it, and re-rendering. Each feature is therefore two steps, and the render function is the single place where data becomes display, so a bug in display appears in one function, not scattered across every feature.
A related benefit is testability. Data logic — filtering, sorting, totalling, validating — can be tested on plain arrays and objects, without needing a page at all, by calling functions in the console and checking their return values. Display logic — building elements from data — can be tested separately, by rendering known data and inspecting the resulting elements. That separation of concerns, data logic versus display logic, is a fundamental software design principle, and the DOM chapter is a gentle introduction to it, because the boundary is visible: variables on one side, elements on the other, with a render function bridging them.
When you find yourself reading a value from the page to use it in a calculation, pause and ask where that value came from in the first place. Almost always, it came from a variable, or from user input that was stored in a variable, or from a server response that was parsed into a variable, and that variable is still available, or should be made available, for the calculation. The fix is to keep that variable, or to store the value in a variable at the point where it is first obtained, rather than discarding it after displaying it.
const prices = [500, 1500];
const total = prices.reduce(function (sum, price) { return sum + price; }, 0);
document.querySelector("#total").textContent = "Total: " + total;
// Do not do this: reading "Total: 2000" back from the page to calculate
Check: Why should you not read a price back out of the page text to calculate a total?
Section 40 — Re-render versus patch
Re-rendering rebuilds a whole list from data each time it changes; patching changes only the one element that changed. Re-rendering is simpler and fine for small lists, while patching is faster for very large ones. Start with re-rendering. Re-rendering's simplicity comes from its uniformity: the render function always does the same thing — clear, loop, build, append — regardless of what changed, whether one item was added, one was removed, one was edited, or the whole list was replaced. That uniformity means there is only one code path to test, one place where display bugs can live, and no need to track which item changed or how, because the render function does not care: it rebuilds everything from the current data, and the result is always correct if the data is correct and the render function is correct.
Patching's advantage is performance, and its cost is complexity. For a list of ten items, re-rendering is instantaneous, and patching saves nothing measurable, while adding code to find the changed element, update only its text or class, and handle insertions and deletions individually. For a list of ten thousand items, re-rendering on every keystroke could be noticeably slow, because it rebuilds ten thousand elements for a change to one, while patching updates one element and leaves the rest untouched. That is why frameworks exist: they automate patching, by comparing the old and new displays and updating only the differences, so developers get patching's performance without writing patching code by hand. In plain JavaScript, without a framework, you must choose: re-render for simplicity, or hand-write patching for performance, and the right choice depends on the list's size and update frequency, which you should measure rather than assume.
Patching also has correctness risks that re-rendering avoids. A patch that updates one element's text but forgets to update its class, or that inserts a new element in the wrong position, or that fails to remove a deleted element's listeners, leaves the display out of step with the data in a subtle way, because the display is now a patchwork of old and new states. Re-rendering cannot have that kind of partial-update bug, because it discards the old display entirely and builds a fresh one, so any display error must be in the render function itself, not in an update path.
A middle path exists for cases where re-rendering is almost fast enough but not quite: re-render the changed section, rather than the whole page, or re-render a list but reuse elements for unchanged items through a keyed approach, where each data item has a stable id and its element is reused if the id matches. For the projects in this book, and for most small-to-medium pages, re-rendering is the correct default, and the skill to learn is writing a clean render function, not writing patching code.
function renderList(items) {
const list = document.querySelector("ul");
list.replaceChildren();
for (const item of items) {
const li = document.createElement("li");
li.textContent = item;
list.appendChild(li);
}
}
// Patching, by contrast, updates one element only:
const firstItem = document.querySelector("li");
if (firstItem) {
firstItem.textContent = "Updated item";
}
Check: When is patching worth the extra complexity over re-rendering?
Section 41 — Inspecting the DOM in developer tools
The Elements panel in developer tools shows the live DOM, not the original HTML file. You can expand nodes, edit text and attributes there, and watch JavaScript's changes appear in real time — which makes it the first place to check when the page does not look as expected. Opening developer tools is usually done with F12, or with right-click and Inspect, or with a keyboard shortcut that varies by browser and operating system, and the Elements panel is typically the first tab shown, displaying the page's tree in an expandable, indented form that mirrors the DOM structure. Each element can be expanded to reveal its children, collapsed to hide them, and selected, which highlights the corresponding area on the page and shows its styles, layout, and event listeners in adjacent panels.
Editing in the Elements panel is a powerful way to test a hypothesis without changing your code. You can double-click a piece of text to edit it, double-click an attribute to change its value, add a new attribute, delete an attribute, or drag an element to a new position in the tree, and the page updates immediately to reflect the edit, because you are editing the live DOM, not the HTML file. Those edits are temporary: reloading the page discards them and rebuilds the DOM from the original HTML and scripts, which is exactly what makes them safe for experimentation. A common workflow is: notice a display problem, edit the DOM in developer tools until the display looks right, note what change fixed it, and then make that change in your actual code, so it persists.
The Elements panel also shows information that is not in the HTML, such as elements created by JavaScript, classes added by scripts, and inline styles set by code, all marked in the tree as they currently exist, not as they were originally written. That live view is essential for debugging DOM manipulation, because a script's effect is only visible in the live DOM, and View Source, which shows the original HTML, will not show it. In each case, the live tree is the evidence, and the panel is where you gather it, before you start guessing at code changes.
Other developer tools panels complement the Elements panel for DOM work. The Console, covered in earlier chapters, lets you run JavaScript against the live page, including DOM queries and manipulations, which is a quick way to test a selector or a change without editing a file. The Styles panel, usually beside the Elements panel, shows which CSS rules apply to the selected element, which rules were overridden, and which are inherited, which helps diagnose styling problems that look like DOM problems. The Accessibility panel, in some browsers, shows the accessibility tree for the selected element, which helps diagnose why a screen reader might not announce an element as expected.
// In the Console, test a selector against the live DOM:
console.log(document.querySelectorAll(".card").length);
// $0 in the Console refers to the element selected in the Elements panel
Check: In developer tools, which panel shows the live DOM?
Section 42 — Batching changes for performance
Every change to the page can make the browser recalculate layout. For many changes, build off-page (in a fragment, or with elements not yet inserted) and insert once, or hide a container, change it, then show it again, rather than forcing a recalculation after every single change. The browser's layout process, often called reflow, calculates the size and position of every element affected by a change, and for a small change it is fast, but for many changes in a row it can become the bottleneck, especially if the code alternates between writing a style and reading a layout-dependent value, such as an element's height or position.
Batching is the remedy, and it takes two main forms. The first is to batch writes: build all new elements off-page, as in a fragment or a detached container, and insert them in one step, so the browser does layout work once for the whole batch, not once per element. The second is to batch reads separately from writes: read all the layout values you need first, then make all the style changes, rather than interleaving them, so the browser can do one layout calculation for the reads, and then apply all the writes without intermediate recalculations. That read-then-write ordering is a simple discipline that prevents thrashing, and it applies to any code that both measures and changes layout, such as animations, drag handling, or responsive adjustments. A related technique is to hide a container with display: none, make many changes to its children while it is hidden, and then show it again, because changes to a hidden subtree do not trigger layout for the visible page, and the final show triggers only one layout pass.
It is important to keep performance advice in proportion. For ordinary pages with dozens of elements and occasional updates, the browser is fast enough that batching makes no perceptible difference, and writing clear, straightforward code is more important than optimising layout. Premature optimisation — complicating code for a performance gain that does not matter — is a real cost, because it makes code harder to read and to change, for no user benefit. Batching becomes important when you have measured a problem, such as a list rendering that feels sluggish, an animation that stutters, or a profile in developer tools that shows layout dominating the time.
Developer tools can help you see whether layout is actually a problem. The Performance panel can record a page interaction and show how much time was spent in layout, in scripting, and in painting, and the Rendering panel can highlight areas of the page that are being repainted, which helps identify unnecessary layout work.
// Batch writes with a fragment (one insertion, one layout pass)
const fragment = document.createDocumentFragment();
for (let i = 0; i < 100; i++) {
const li = document.createElement("li");
li.textContent = "Item " + (i + 1);
fragment.appendChild(li);
}
document.querySelector("ul").appendChild(fragment);
// Batch reads before writes (avoid interleaving)
const heights = [...document.querySelectorAll(".card")].map(function (card) {
return card.offsetHeight;
});
console.log(heights);
Check: Why is inserting 100 items one by one slower than inserting them together?
Section 43 — Accessibility when the DOM changes
When content changes, make sure the change is available to everyone: new content should be real text, not only a colour change; important updates may need an announcement region; and focus should go somewhere sensible after a panel opens or an item is removed. A page that starts accessible can become inaccessible through a script that updates content in a way that only sighted mouse users can perceive, and fixing that requires thinking about how the change is communicated through text, through announcements, and through focus, not just through visuals.
The first principle is that information must not be conveyed by colour alone. Changing an element's colour from grey to red to signal an error, without changing its text or adding an icon with text, leaves colour-blind users and screen-reader users without the signal, because they cannot perceive the colour change, or because the colour is not announced. The fix is to pair every colour change with a text change, an icon with alternative text, or a class that also changes the text, so the information exists in a non-colour form as well. Similarly, a status that changes only by an icon's shape, without text, fails the same test, and should be paired with text or with an accessible label.
The second principle is live regions for important updates. A live region is an element with an aria-live attribute, such as aria-live="polite", which tells screen readers to announce changes to its content when they happen, without the user needing to navigate to it. A shopping cart count, a form success message, or a search results count are examples of updates that should be announced, because the user needs to know they happened even if they are not currently focused on that part of the page. Without a live region, a screen-reader user might not discover that a cart count changed or that a form succeeded, because they are focused elsewhere and the change is silent. With a live region, the change is announced politely, after the current speech finishes, which is the right level of interruption for most updates. aria-live="assertive" is for urgent updates that should interrupt, and it should be used sparingly, because interrupting a user's current task is disruptive, and most updates are not urgent enough to justify it.
The third principle is focus management after structural changes, as Sections 33 and 35 noted. When an item is removed, focus should not be lost; it should move to a sensible neighbour, such as the next item or the list itself, so keyboard users are not left without a position. When a panel opens, focus should move into it or to its heading, so users can find the new content without searching. Testing dynamic features with a keyboard only, without a mouse, is the practical way to verify these principles, because it reveals focus losses and silent updates that are invisible to mouse testing.
<p aria-live="polite" id="status">Ready</p>
document.querySelector("#status").textContent = "3 items added to your cart";
// Focus after removal: move to the next item, or to the list
const list = document.querySelector("ul");
if (list.firstElementChild) {
list.firstElementChild.setAttribute("tabindex", "-1");
}
Check: Why is changing only an element's colour a poor way to signal an important update?
Section 44 — Common DOM mistakes
The classic mistakes are: querying before the DOM is ready, forgetting that querySelectorAll() returns a NodeList without map(), calling a method on a null result, using innerHTML with user text, and re-rendering without clearing the container first. Check these in that order when a script fails silently. Timing first, because a script that runs too early fails on its first DOM line, which is a distinctive symptom: the error is at the top of the script, not deep inside a function, and moving the script to the end of the body fixes it. NodeList second, because a map is not a function error points directly at a NodeList being treated as an array, which is a distinctive error message that should immediately suggest conversion to an array. Null third, because a "cannot read properties of null" error points at a failed search, whose cause is usually a misspelled selector, a missing element, or timing, in that order of likelihood.
The remaining two checklist items are not errors in the console sense, but bugs in behaviour, which makes them harder to spot and therefore more important to check deliberately.
Other mistakes belong on a second-tier checklist, to be consulted when the first five do not explain a bug. Appending an element that is already on the page, expecting a copy but getting a move, is one, and its symptom is a missing item in one list and an extra item in another, with no error. Forgetting that style only shows inline styles, and therefore reading an empty string for a stylesheet-set colour, is another, and its symptom is a logged empty string where a colour was expected, which Section 26 explains. Treating a NodeList's length as a count of elements after a live collection changed, or vice versa, is a third, and its symptom is a count that does not match the visible list, which Section 31 explains. Cloning without fixing IDs or listeners, so that a cloned button does nothing or a duplicated ID breaks a search, is a fourth, and its symptom is a feature that works for the original but not for the clone, which Sections 16 and 25 explain.
The general debugging approach that ties the checklist together is to inspect the live DOM in developer tools, as Section 41 described, and to log the data and the searched elements, rather than reasoning about code in the abstract. When a display is wrong, look at the live tree: is the element there, in the right place, with the right text, classes, and attributes? When a search fails, log the search result: is it an element, a NodeList of the expected length, or null? When a list duplicates, check the render function: does it clear first?
// Checklist in code form: guard, convert, clear
const heading = document.querySelector("h1");
if (heading) {
heading.textContent = "Safe update";
}
const cards = [...document.querySelectorAll(".card")];
console.log(cards.length);
const list = document.querySelector("ul");
list.replaceChildren(); // clear before re-rendering
Check: Your script finds nothing and throws an error on the first DOM line. What is the most likely cause?
Section 45 — A reusable render function
Wrap the "clear the container, loop over data, build elements, append them" steps in one function, such as renderList(items). Every change then follows the same path: update the data, call the function, and the page follows. That wrapping is the most important organisational step in this chapter, because it turns a repeated sequence of steps into a single, named operation, with a clear contract: given data, produce display. Without the function, every feature that changes the list must repeat the clear-loop-build-append sequence, and any improvement or bug fix to that sequence must be made in every place it appears, which is error-prone and tedious. With the function, there is one place to fix, one place to test, and one name to call, which makes the rest of the code shorter and clearer: features become data changes plus a render call, and the render function handles the DOM details.
A good render function takes the data as an argument, rather than reading a global variable, because that makes it explicit what data it renders, and it allows the same function to render different lists, or to be tested with different data in the console, without changing global state. It should also be idempotent, meaning that calling it twice with the same data produces the same display, without duplicates or leftovers, which is guaranteed by the clear-first step, as Section 22 explained. That "just call render" simplicity is why the render pattern is so popular, even though it rebuilds the whole list each time, as Section 40 discussed.
The render function's internal structure should follow the same steps, in the same order, every time: find the container, clear it, loop over the data, build an element per item, fill it from the item's data, and append it, directly or via a fragment. That consistent internal order makes the function easy to read and to extend: adding a new field to each item means adding a new element-building step inside the loop, not restructuring the function. For lists of objects, the element-building step itself may call a helper function that builds one item's element, as Section 38 showed for table rows, which keeps the render function short even when each item's element is complex.
Practise the render function by building it for a simple list, then using it for every change to that list: add an item by pushing to the data and calling render, remove an item by splicing the data and calling render, and sort the list by sorting the data and calling render. Notice how each feature's code becomes two lines — data change, render call — and how the display always matches the data afterwards, without any direct DOM edits outside the render function. That discipline, of never editing the list's display except through the render function, is the practical rule that makes the pattern work, because any direct edit outside the function will be overwritten by the next render, causing confusion.
function renderList(items) {
const list = document.querySelector("ul");
list.replaceChildren();
for (const item of items) {
const li = document.createElement("li");
li.textContent = item;
list.appendChild(li);
}
}
const tasks = ["Buy milk", "Read chapter"];
renderList(tasks);
tasks.push("Walk the dog");
renderList(tasks);
Check: What are the three steps inside a render function?
Section 46 — Reading state from form controls
Checkboxes and radio buttons use the checked property (true or false), while select menus use value for the chosen option. Reading checked tells you whether a box is ticked, regardless of what its label says. That distinction is essential, because a checkbox's value property is not its ticked state: it is the value that would be submitted if the box is checked, which defaults to "on" if no value attribute is written, and which stays the same whether the box is ticked or not. Reading value to test whether a box is ticked therefore gives a misleading result — a string that is always present, even when the box is unticked — while reading checked gives the boolean state directly, which is what most code actually needs, for example to decide whether a user agreed to terms, or whether a filter option is active.
Radio buttons work as a group, where several buttons share the same name attribute, and only one of them can be checked at a time within that group. Reading the group's state means finding which button in the group is checked, which can be done with a selector that combines the name and the :checked pseudo-class, as in document.querySelector('input[name="size"]:checked'), which returns the checked button or null if none is checked. Setting a radio button's checked to true checks it and unchecks the others in its group automatically, because the browser enforces the one-checked rule for a group, so you do not need to uncheck the others manually, which is a convenience that follows from the group's shared name.
Select menus, both single and multiple, use value for single selects, as Section 20 noted, and selectedOptions for multiple selects, which returns a collection of the selected option elements, from which you can map to their values. A single select's selectedIndex property gives the index of the selected option, or -1 if none is selected, which is useful when you need the position rather than the value, for example to find the next option. For all form controls, the pattern is the same: find the control, read its state property — value for text and selects, checked for boxes and radios — and use that state, rather than reading attributes or text, which describe the starting state or the label, not the current state.
Form state reading is the first step in validation and in building a data object from a form, as the next chapter's form handling will show. A form's data is collected by reading each control's state property, assembling the values into an object, and then validating or sending that object, which is the data-versus-display separation applied to forms: the controls are the display, the collected object is the data, and the collection step bridges them. That contrast, between changing state properties and unchanging attributes, is the lesson, and it will make form handling feel straightforward rather than mysterious.
const box = document.querySelector("#agree");
console.log(box.checked); // true or false
console.log(box.value); // submission value, not ticked state
const chosen = document.querySelector('input[name="size"]:checked');
console.log(chosen ? chosen.value : "none chosen");
const select = document.querySelector("select");
console.log(select.value);
Check: How do you test whether a checkbox is ticked?
Section 47 — Attributes versus properties
An attribute is the starting value written in HTML; a property is the live value in the DOM, which can change as the user interacts. For an input, the attribute is where the value started, and the property is what the user has actually typed. The general rule is: attributes initialise properties, and properties then live their own lives, changing with interaction and with script assignments to the property, while attributes stay as written, unless changed explicitly with setAttribute().
The input value case is the clearest example. An input written as <input value="Ada"> starts with both its value attribute and its value property equal to "Ada". When the user types over it, changing the displayed text to "Bola", the property becomes "Bola", while the attribute remains "Ada", because the attribute is a record of the starting value, not a mirror of the current value. Reading getAttribute("value") after typing therefore gives "Ada", which is wrong if you wanted the current input, and setting setAttribute("value", "Chidi") changes the attribute but does not necessarily change the displayed text or the property, depending on the browser's handling of dirty inputs, which is a subtlety that catches developers who try to reset a form by setting attributes. The correct way to read the current value is the property, input.value, and the correct way to change the displayed value is to assign to the property, input.value = "Chidi", while the attribute should be left alone except when you deliberately want to change the starting value, for example for a form reset, which restores properties from attributes.
Other attribute-property pairs follow similar patterns with their own details. A checkbox's checked attribute sets its initial checked state, while its checked property reflects its current state, changing when the user ticks or unticks it, or when script assigns to the property. An image's src attribute and property both change when either is set, but the property returns a fully resolved URL while the attribute returns the written value, which may be relative, a difference that matters when comparing URLs as strings. A link's href behaves similarly, with the property resolved and the attribute as written.
The practical guideline is to use properties for current state and for everyday reading and writing, and to use attributes for initial values, for custom data, and for attributes that have no property counterpart, such as aria- attributes or data- attributes handled through dataset. When you are unsure whether to use an attribute method or a property, ask whether you care about the starting value or the current value: starting value means attribute, current value means property. That question resolves most cases, and it is a good diagnostic when code reads a stale value: check whether it read an attribute when it should have read a property, because that is the most common cause of "the value never updates" bugs in form code.
const input = document.querySelector("#name");
console.log(input.value); // current typed value
console.log(input.getAttribute("value")); // starting value from HTML
input.value = "New value";
console.log(input.value); // "New value"
console.log(input.getAttribute("value")); // still the starting value
Check: A user types over an input's starting value. Which changes — its attribute or its property?
Section 48 — Updating counts and totals from data
When a list changes, totals should be recalculated from the data array (for example, by summing prices), not by adding numbers read from the page. That way the displayed total always matches the data, even after items are added or removed. The principle is the data-versus-display separation of Section 39, applied to derived values: a total is derived from data, so it should be computed from data, not from a previous total plus a change, and not from displayed text that has been formatted for humans.
The standard tool for summing from data is reduce(), which walks an array and combines its items into a single value, starting from an initial value. For prices, prices.reduce((sum, price) => sum + price, 0) starts at 0 and adds each price in turn, giving the total, and the same pattern works for counts, averages, and other aggregates, by changing the combination function and the initial value. A manual loop that sums into a variable is equally correct, and it is clearer for beginners, so either form is acceptable; what matters is the source — the data array — not the specific summing method.
Counts follow the same principle, with an even simpler computation: a count is usually the data array's length, or a filtered array's length, as in products.filter(p => p.inStock).length for the count of in-stock products. That filtered length is always correct if the data and the filter are correct, while a count maintained by incrementing and decrementing a variable on each add and remove can drift if an add or remove path forgets to update the count, which is a common bug in code that maintains counts separately from data. The habit is to derive counts and totals on demand, from data, at render time, rather than storing them as separate variables that must be kept in sync, because a derived value cannot drift: it is computed fresh each time from the source of truth, so it is always in step, by construction. That habit trades a tiny amount of recomputation, which is negligible for ordinary list sizes, for a large amount of correctness, which is valuable in every list feature.
Displaying the computed total is then a textContent assignment, as in earlier sections, with formatting applied at display time, such as adding a currency symbol or fixing decimal places, while the underlying total remains a plain number in the data world. That formatting-at-display rule keeps the total usable for further maths, because the displayed string is not the total itself, only its presentation, and the next calculation should start from the numeric total, not from the formatted string.
const prices = [500, 1500];
const total = prices.reduce(function (sum, price) { return sum + price; }, 0);
console.log(total); // 2000
document.querySelector("#total").textContent = "Total: " + total;
const inStock = [{ inStock: true }, { inStock: false }];
console.log(inStock.filter(function (p) { return p.inStock; }).length); // 1
Check: Why calculate a total from the data array rather than from the displayed list?
Section 49 — When not to touch the DOM directly
Some updates belong in CSS, not JavaScript: hover effects, simple transitions, and layout changes driven by screen size. Reaching for JavaScript for those jobs adds code that CSS already handles more smoothly and with less work. A hover effect, for example, is a single CSS rule with :hover, which the browser applies and removes automatically as the mouse moves, with no JavaScript needed, no listeners to attach, and no state to track. That comparison — one CSS rule versus several JavaScript lines — is the test for whether a visual change belongs in CSS: if CSS can express it declaratively, let CSS do it.
Transitions and animations are a similar case. A CSS transition between two states, triggered by a class toggle, runs smoothly on the browser's compositor, often without involving JavaScript at all after the toggle, and it can be defined with easing, duration, and delay in a few lines of CSS. A JavaScript animation that updates styles in a timer loop must manage timing, easing, and cleanup itself, and it is more likely to stutter, because it runs on the main thread alongside other JavaScript. That division, JavaScript for state and CSS for appearance and motion, keeps both layers simple and lets each do what it does best.
Layout changes driven by screen size belong in CSS media queries, not in JavaScript resize listeners that set styles based on window width. A media query like @media (max-width: 600px) applies a different layout automatically as the window resizes, without any JavaScript, without any event handling, and without any risk of the layout and the window size getting out of step, which can happen if a resize handler misses an event or runs with stale data. Reaching for JavaScript for responsive layout is therefore a sign that the CSS approach has not been considered, or that the layout problem is genuinely data-driven, which should be the exception, not the default.
The general principle is to use the lowest-level tool that can express the change: CSS for appearance, layout, and simple state visuals; HTML for structure and semantics; JavaScript for behaviour, data, and interaction that CSS and HTML cannot express. That principle, sometimes called "use the platform", keeps code smaller, faster, and more accessible, because platform features like CSS hover and media queries come with built-in accessibility and performance characteristics that hand-written JavaScript must replicate. When you are about to write JavaScript for a visual change, pause and ask whether CSS can do it with a selector, a class, or a media query, and whether HTML can do it with a native element, such as a details element for a disclosure, which provides open-and-close behaviour without any JavaScript at all. That pause, applied consistently, will save you from a surprising amount of unnecessary JavaScript, and it will make the JavaScript you do write focused on the behaviour that genuinely needs it.
.card:hover { transform: translateY(-4px); }
@media (max-width: 600px) { .grid { grid-template-columns: 1fr; } }
// JavaScript only toggles state; CSS handles the visual change
document.querySelector("#menu-button").addEventListener("click", function () {
document.querySelector("#menu").classList.toggle("open");
});
Check: Give an example of a visual change better handled by CSS than by JavaScript.
Section 50 — Chapter 19 in one paragraph
DOM manipulation is finding elements, reading and changing their text, attributes, classes and styles, and creating, moving and removing nodes — always from data you hold in JavaScript, rendered through small, reusable functions. Master the finders, the safe text methods, and the render pattern, and you can turn any static page into one that responds to its data, because every interactive feature, from a task list to a product grid to a comment thread, is a variation on that pattern, with different data and different element structures, but the same underlying operations.
The habits that make DOM code reliable are worth restating as a closing checklist, because they are the difference between code that works in a demo and code that works in a real page. Guard against null, so a missing element does not crash the script. Use textContent for user text, so display cannot become injection. Clear before re-rendering, so lists do not duplicate. Keep data and display separate, so totals and counts are computed from data, not read from formatted text. Prefer classes to inline styles for states, so appearance lives in CSS and behaviour lives in JavaScript. And wait for the DOM to be ready, so scripts find the elements they need.
Looking ahead, the next chapter adds the missing piece that makes DOM manipulation interactive: events. So far, the code in this chapter has run on load, or in the console, changing the page in response to a script, not in response to a user. Events will let a click, a key press, or a form submission trigger a data change and a re-render, completing the loop from user action to data update to display update, which is the core loop of every interactive page. The render functions you built here will become the display half of that loop, and the event handlers in the next chapter will become the action half, with data in the middle, unchanged in structure but now driven by users instead of by scripts. That loop — action, data, render — is the architecture of front-end interactivity in miniature, and this chapter has given you two of its three parts, with the third to follow.
Before moving on, test yourself on the chapter's core operations without looking back: find an element by ID, by class, and by a scoped query inside a card; change its text safely, toggle its class, and change an attribute; create a new element, fill it, and append it; build a list from an array with a render function that clears first; and explain why innerHTML with user text is dangerous and why a NodeList is not an array. If any of those feels shaky, re-read its section and try its console examples, because the events chapter assumes them and will move faster, combining DOM updates with event handling in every example. A solid DOM foundation makes events straightforward, because events are simply the triggers that cause the DOM updates you already know how to write.
function renderList(items) {
const list = document.querySelector("ul");
list.replaceChildren();
for (const item of items) {
const li = document.createElement("li");
li.textContent = item;
list.appendChild(li);
}
}
Check: In your own words, what are the three main jobs of DOM manipulation?
Practical task
Build a "task list" page: start with an array of at least three task strings in JavaScript and a render function that clears an unordered list and rebuilds it from the array. Add a text input and an "Add task" button that pushes the typed text into the array (ignoring empty input) and re-renders. Add a "Clear all" button that empties the array and re-renders. Use textContent and createElement — not innerHTML with user input — and test it by adding and clearing tasks in the browser. Extend it if you finish early: add a delete button to each task that removes that one item from the array and re-renders, and a count element that shows the number of tasks, recalculated from the array on every render.
Chapter questions
- Explain the difference between textContent and innerHTML, and say which one you should use for text typed by a user.
- Write code that finds every element with class "card" and adds the class "highlight" to each one.
- Why does a script placed in the head, without defer, often fail to find elements on the page?
- Describe the render pattern: what steps does a render function take, and why should you clear the container first?
- A page shows duplicate list items every time a button is clicked. What is the most likely bug, and how do you fix it?