JavaScript DOM: Select Elements and Update the Page
A typical page script finds elements, changes their text safely, switches classes, reads the data attached to them, and builds or deletes elements from JavaScript.
This is part of our JavaScript and the DOM guides. Start with the JavaScript overview if functions and objects are new to you.
Everything here needs a browser rather than Node, since Node has no page to select from. Save an HTML file, load your JavaScript from it with <script defer src="app.js"></script>, and open it in a browser with the Console tab showing. Each snippet needs the matching HTML elements shown or named beside it.
How Do You Select a DOM Element in JavaScript?
Use document.querySelector with a CSS selector to get the first matching DOM element. It returns null when nothing matches. These examples select an ID, a class, and a data attribute:
<h1 id="title">Weekly Report</h1>
<p class="status">Not started</p>
<button data-action="refresh">Refresh</button> const title = document.querySelector("#title");
const status = document.querySelector(".status");
const refreshButton = document.querySelector("[data-action='refresh']"); A hash finds an id, a dot finds a class, a bare word finds a tag, and square brackets find an attribute. Valid CSS selectors work here, including descendant selectors such as .card h2, though an invalid string throws and a pseudo-element selector matches nothing.
The search also has edges worth knowing. It stays inside the document or element you called it on, so it never reaches into an iframe or a shadow root, and an id or class generated at runtime may need CSS.escape() before it is safe to paste into a selector.
When nothing matches you get null, not an error. That is worth knowing early, because the error arrives one line later and blames the wrong thing:
const missing = document.querySelector("#does-not-exist");
console.log(missing);
missing.textContent = "hello"; // browser console, illustrative wording:
// null
// TypeError: Cannot set properties of null (setting 'textContent') Read that message as a selector problem rather than a text problem. The usual causes are a wrong selector, a script that ran before the element existed, or a search started from the wrong root, such as a different document or an iframe.
The timing case is the one people meet first, and the lifecycle guide covers it.
Older code uses getElementById, getElementsByClassName, and getElementsByTagName. They still work, and you will meet them, but one method with CSS selectors is less to remember.
getElementById is available on Document and DocumentFragment, including shadow roots, but not on ordinary elements. It takes the raw ID rather than a CSS selector, so an ID containing punctuation needs no selector escaping.
How Do You Select Multiple DOM Elements?
querySelectorAll returns every match rather than the first. What comes back is a NodeList, which looks like an array and is not one:
const rows = document.querySelectorAll(".row");
console.log(rows.length);
rows.forEach((row) => row.classList.add("loaded"));
const titles = Array.from(rows).map((row) => row.textContent); A NodeList has length, index access, and forEach. It does not have map, filter, or reduce, so Array.from or a spread into [...rows] is the usual first move when you want those.
There is one more difference worth carrying. A querySelectorAll result is static. It is a snapshot taken at the moment you asked, and elements added afterwards are not in it.
The older getElementsByClassName returns a live HTMLCollection instead, which does update itself as the page changes. Removing items while looping forward over a live collection skips entries, because the indexes shift underneath you.
You can also search inside one element rather than the whole document. Every element has querySelector and querySelectorAll, and narrowing the search root is the reliable way to stop a selector matching something you did not mean:
const card = document.querySelector("#report-card");
const cardLinks = card.querySelectorAll("a"); How Do You Change Text with JavaScript?
Setting textContent replaces everything inside an element with a single piece of text. Whatever you assign is treated as text, so angle brackets and quotes arrive on screen exactly as typed:
const status = document.querySelector(".status");
status.textContent = "Ready";
status.textContent = "5 < 10 is true"; The second line shows the useful part. The reader sees the literal characters 5 < 10 is true, because nothing in that string is treated as markup.
Two neighbors come up in older code. Read from a rendered element, innerText is aware of styling, so it skips hidden elements and returns roughly what a person can see, at the cost of possibly forcing the browser to work out the layout first.
Read from an element that is not rendered, or one you built and have not attached yet, it falls back to something close to textContent. That inconsistency is a second reason not to reach for it.
textContent ignores styling and returns the text of everything inside, including script and style elements. It is the simpler and cheaper of the two, and it is the right default for setting text.
When Should You Use innerHTML?
Setting innerHTML hands the browser a string and asks it to parse the string as markup. That is useful when you are inserting a structure you wrote yourself:
const card = document.querySelector("#report-card");
card.innerHTML = "<h2>Weekly Report</h2><p>Nothing due today.</p>"; The risk starts when any part of that string came from somewhere you do not control: a form field, an API response, a URL, a database row somebody else can write to. The browser cannot tell your markup from theirs.
A script element inserted this way does not run, and that holds for module scripts too, which is the detail people remember and then over-trust. Attribute-based handlers can still run, and that is enough:
const nameFromUser = "<img src='x' onerror='alert(1)'>";
card.innerHTML = nameFromUser; The image request fails, its error handler fires, and whatever sat in that attribute has just executed on your page with your user's session. Two things have to hold for it: the source has to fail to load, and no Content Security Policy on the page may be blocking inline handler attributes.
An event-handler attribute in untrusted HTML can run code on your page. A Content Security Policy can reduce the damage, but it does not replace safe insertion of text or sanitization of HTML.
Three rules keep this simple. Use textContent whenever the value is text, which is most of the time, and build structure with createElement while setting the text separately. If you must insert markup that came from elsewhere, run it through a maintained sanitizer such as DOMPurify first, rather than filtering it yourself.
The += shortcut deserves its own warning. Writing card.innerHTML += "<p>more</p>" re-parses the entire contents, which throws away and rebuilds every element inside. Listeners registered on those descendants go with them, along with live state such as a partly typed input, though a listener on card itself survives.
How Do You Change CSS Classes with JavaScript?
Use classList.add, remove, and toggle to change CSS classes without replacing the entire class attribute. Keep reusable styling in the stylesheet and let JavaScript choose the state.
const panel = document.querySelector("#filters");
panel.classList.add("is-open");
panel.classList.remove("is-loading");
panel.classList.toggle("is-open");
console.log(panel.classList.contains("is-open")); All four methods accept a class name without a leading dot, and add and remove take several names at once. toggle returns true when the class ended up present and false when it ended up absent.
toggle also takes a second argument that decides the outcome instead of flipping it. This is the version to reach for when a condition already tells you the answer:
panel.classList.toggle("is-open", itemCount > 0); Use element.className only when you intend to replace every class. It replaces the whole class attribute, so a single assignment silently drops every other class the element was carrying.
How Do You Read HTML Attributes and data Values?
Use getAttribute and setAttribute to read or write HTML attributes by name. Use dataset for custom data- attributes. Element properties can differ from the original attribute, especially for form values.
const link = document.querySelector("#docs-link");
console.log(link.getAttribute("href"));
link.setAttribute("href", "/programming/javascript/");
link.removeAttribute("target"); Many attributes also exist as properties on the element, and the property is usually the nicer one to use. link.href, input.value, and checkbox.checked all read and write live state.
The two are not always the same thing. An input's value property is what the person has typed right now, while its value attribute is the starting value from the HTML, and after typing they disagree.
For your own information, use data- attributes rather than inventing attribute names. The browser gathers them into a dataset object, dropping each dash that comes before a lowercase letter and capitalizing that letter:
<button data-action="delete" data-item-id="42">Delete</button> const button = document.querySelector("button");
console.log(button.dataset.action);
console.log(button.dataset.itemId);
button.dataset.itemId = "43"; So data-item-id in the HTML becomes dataset.itemId in JavaScript. Write the attribute with dashes and lowercase letters, because a capital letter in the HTML is lowercased before the conversion and will not produce the property you expected.
Every value in dataset is a string. An id read back as "42" needs Number(...) before you do arithmetic with it, which is the same coercion trap covered in Values, Variables, and Equality.
How Do You Create and Remove DOM Elements?
Create a DOM element with document.createElement, set its content, and attach it with append. Call remove to detach an element, or replaceChildren to replace a container's contents.
const list = document.querySelector("#tasks");
const item = document.createElement("li");
item.textContent = "Send the weekly report";
item.classList.add("task");
item.dataset.itemId = "42";
list.append(item); The reader sees a new list item appear at the end of the list, styled by whatever your stylesheet says about .task. Nothing about the HTML file on the server changed.
append is the modern method and the more flexible one: it takes several nodes at once, and it accepts plain strings, which it inserts as text. The older appendChild takes exactly one node and rejects strings.
Adding many elements one at a time touches the live page once per element. A DocumentFragment is a holding area that is not part of the page, so the whole batch reaches the document in a single insertion:
const fragment = document.createDocumentFragment();
for (const task of tasks) {
const item = document.createElement("li");
item.textContent = task.title;
fragment.append(item);
}
list.append(fragment); Removing is simpler than it used to be. Call remove on the element itself, with no reference to its parent needed:
document.querySelector("#tasks .task").remove();
list.replaceChildren(); The second line empties the list completely. replaceChildren with no arguments is the readable way to clear an element, and it replaces the old habit of assigning an empty string to innerHTML.
Pitfalls and Debugging
Cannot read properties of null. The selector matched nothing at that moment. Print the result of querySelector on its own line first, then check the spelling, the search root, and whether your script runs before the element is parsed.
A class change did nothing visible. The class is on the element but no CSS rule targets it. Select the element in Chrome DevTools and look at the Styles pane, which shows the rules that matched and the ones that were overridden; other browsers have the same view under their own names.
Every other class vanished. Something assigned className instead of calling classList.add. The whole attribute was replaced.
Text arrived with visible tags in it. Content that was meant as markup went through textContent, which is the safe direction of that mistake. Use createElement to build the structure, and keep the text going through textContent.
Listeners stopped working after an update. Rewriting innerHTML destroys the elements inside and builds new ones, and the new ones have no listeners. The events guide shows the delegation pattern that survives this.
The loop skipped items. A live HTMLCollection from getElementsByClassName shrinks as you remove elements from it, so a forward loop moves past entries it never visited. Take a static snapshot with querySelectorAll, or convert to an array first.
Nothing appeared even though there was no error. The element was created and filled but never attached with append. Log the element and check whether it has a parent.
Frequently Asked Questions
Should JavaScript still use getElementById?
JavaScript getElementById is still supported and useful when you know an element's ID. It accepts the raw ID without CSS escaping. Use querySelector when you need a CSS selector or want to search inside an ordinary element.
Why does map not work on a querySelectorAll result?
A querySelectorAll result is a NodeList, which has forEach but not the array method map. Convert it with Array.from or spread syntax before using map, filter, or reduce.
Is assigning innerHTML always unsafe?
Assigning innerHTML is risky when the string contains untrusted content because the browser parses it as markup. A fixed, trusted HTML string is different. Use textContent for plain text and a maintained sanitizer when you must accept external markup.
Why can changing className remove CSS styling?
Assigning className replaces an element's whole class attribute, so CSS rules for the removed classes stop matching. Use classList.add or classList.remove when you only want to change one class, and inspect the resulting classes in browser developer tools.
Sources
-
[1]
Document: querySelectorAll() Method(developer.mozilla.org)
-
[2]
Element: innerHTML Property(developer.mozilla.org)
-
[3]
HTMLElement: dataset Property(developer.mozilla.org)
Read Next
addEventListener and the event object, preventDefault, one delegated listener for a whole list, reading a form with FormData, the browser's own validation, and honest loading states.
removeEventListener and why it so often does nothing, retiring listeners with one AbortController, script defer against DOMContentLoaded, and why these leaks survive a framework.
The JavaScript that changes a web page: finding and updating elements, reacting to clicks and forms, and taking your listeners back off again.