Selecting Elements and Updating the Page

Published Updated

By the end of this page you will be able to find elements on a page, change their text safely, switch classes, read the data attached to them, and build or delete elements from JavaScript.

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. Most examples describe what you would see, because the result of this work is a changed page.

Finding an Element

One method does most of the work. document.querySelector takes a CSS selector, the same syntax you already write in a stylesheet, and hands back the first element that matches:

<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 the odd one out, because it belongs to the document rather than to elements. Calling it on an element throws, and there would be no point anyway, since it searches the whole document and ids are meant to be unique in it.

Finding Many 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");

Putting Text on the Page

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 neighbours 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 You Actually Need innerHTML

Setting innerHTML hands the browser a string and asks it to parse the string as markup. That is genuinely 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 actually fail to load, and no Content Security Policy on the page may be blocking inline handler attributes.

MDN uses this exact shape as its worked example of why the property is the most common route into a cross-site scripting bug. Treat the CSP as a second line of defence rather than the reason you are safe.

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 genuinely must insert markup that came from elsewhere, run it through a maintained sanitiser 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.

Adding and Removing Classes

Styling from JavaScript is best done by switching classes rather than by writing styles directly. The CSS stays in the stylesheet and the JavaScript only decides which state applies:

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);

Avoid assigning element.className directly. It replaces the whole class attribute, so a single assignment silently drops every other class the element was carrying.

Attributes and Data Values

Attributes are what you wrote in the HTML, and getAttribute and setAttribute read and write them by name:

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 capitalising 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.

Creating and Removing Elements

Building an element takes three steps: create it, fill it, then attach it. Nothing appears on screen until the third step, because an element that is not in the tree is not on the page:

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 you still use getElementById?

It works and it is not deprecated, so existing code using it is fine. For new code querySelector covers the same ground, since it takes any CSS selector, including an id written with a hash. It is also the one you can call on an element, because getElementById belongs to the document and searches all of it.

Why does map not work on querySelectorAll?

Because a NodeList is not an array. It has length, index access, and forEach, but not map, filter, or reduce. Wrap it in Array.from, or spread it into an array, and every array method becomes available on the result.

Is innerHTML always unsafe?

No. The risk is assigning a string you did not fully control, such as a value from a person, an API, or a URL, because the browser parses it as markup and attribute-based handlers inside it can run. A fixed string you wrote yourself carries no such risk. Use textContent whenever the value is text.

Why did my styles stop applying after a class change?

Usually because className was assigned rather than added to, which replaces every class the element had. Use classList.add and classList.remove to change one class and leave the others alone. Check the element in the browser tools to see which classes it actually carries.

Sources

  1. [1]
  2. [2]
    Element: innerHTML Property
    (developer.mozilla.org)
  3. [3]
    HTMLElement: dataset Property
    (developer.mozilla.org)