RizTech Academy logo
RizTech Academy
The DOMLesson 2 of 725 min

Selecting and changing elements

Finding the node you want is half of DOM work. There are four ways to do it, two of which you should use and two of which you will meet in older code — one of those returns a list that changes while you are looking at it.

The two to use

const heading = document.querySelector('#heading');
const orders = document.querySelectorAll('.order');

querySelector takes any CSS selector and returns the first match, or null. querySelectorAll returns all matches. Everything you know from CSS works:

document.querySelector('#orders');
document.querySelector('.order');
document.querySelector('li.order[data-id="2"]');
document.querySelector('#orders li:last-child');
document.querySelectorAll('input[type="checkbox"]:checked');

One API for everything, and the selector is testable — paste it into the console and see what comes back.

A failed querySelector returns null, not an error:

console.log(document.querySelector('.nope'));
console.log(document.querySelectorAll('.nope').length);
null
0

So the classic first bug is Cannot read properties of null, from module 4 — and on a page it almost always means your selector is wrong, or the script ran before the element existed (module 1's defer).

The older two

document.getElementById('heading');
document.getElementsByClassName('order');
document.getElementsByTagName('li');

getElementById is fine — marginally faster and perfectly clear. The others have a sharp edge.

The trap: live collections

const live = document.getElementsByClassName('order');
const fixed = document.querySelectorAll('.order');

console.log(live.length, fixed.length);

const extra = document.createElement('li');
extra.className = 'order';
document.querySelector('#orders').append(extra);

console.log(live.length, fixed.length);
3 3
4 3

getElementsByClassName returns a live HTMLCollection — a standing query, not a snapshot. Add a matching element anywhere and it grows. Remove one and it shrinks.

querySelectorAll returns a static NodeList: the matches as they were, and it never changes.

Live collections produce a genuinely nasty bug:

const items = document.getElementsByClassName('order');

for (let i = 0; i < items.length; i++) {
  items[i].remove();
}

That removes roughly half of them. Remove index 0 and everything shifts down, but i still goes up — so you skip every other one. The same loop over a querySelectorAll result works, because the list does not move.

Use querySelectorAll and the problem does not exist.

A NodeList is not an array

const orders = document.querySelectorAll('.order');

console.log(Array.isArray(orders));
console.log(typeof orders.forEach);
console.log(typeof orders.map);
false
function
undefined

forEach works; map, filter and reduce do not. That catches everybody, because forEach working implies the rest should.

Convert with spread:

const names = [...document.querySelectorAll('.order')].map(
  (el) => el.textContent,
);

An HTMLCollection does not even have forEach, so [...collection] is required there.

Searching within an element

querySelector exists on every element, not just document:

const row = document.querySelector('#orders li');
const plates = row.querySelector('.plates');

Scoping the search to a row rather than the whole document is faster and, more importantly, correct when the page has several rows.

Changing what you found

Text

element.textContent = 'Priya';

textContent is the safe default. It sets text, and anything that looks like a tag stays text:

const probe = document.createElement('div');
probe.textContent = '<b>bold</b>';
console.log(probe.innerHTML);
&lt;b&gt;bold&lt;/b&gt;

You will also meet innerText, which respects CSS and skips hidden elements. It also forces a layout calculation, so it is slower. Use textContent unless you specifically need what the user can see.

HTML, and the risk

element.innerHTML = '<strong>Priya</strong> — 3 plates';

Parsed as markup. Convenient, and never do this with anything a user typed.

Here is the part people get wrong. Try it and an inserted <script> tag does not run:

container.innerHTML = '<script>alert(1)<\/script>';
(nothing happens)

From which people conclude innerHTML is safe. It is not:

container.innerHTML = '<img src=x onerror="alert(1)">';
(the alert fires)

The script tag does not execute, but an event handler attribute does. The image fails to load, onerror runs, and that is a full cross-site scripting hole — an attacker running code as your user, reading whatever they can see.

So: textContent for anything from a person; innerHTML only for markup you wrote yourself. Module 8's practice builds a page from fetched data and this rule is what keeps it safe.

Attributes, properties and data

const input = document.createElement('input');
input.setAttribute('value', 'initial');
input.value = 'typed';

console.log(input.value);
console.log(input.getAttribute('value'));
typed
initial

The attribute is the starting value from the HTML; the property is the current state. For a form control they drift apart the moment the user types — which is why reading getAttribute('value') to find out what somebody entered returns the wrong thing, and why the next-but-one lesson always uses .value.

Custom data goes in data- attributes:

<li class="order" data-id="1">Priya</li>
console.log(row.dataset.id);
console.log(typeof row.dataset.id);
1
string

Always a string — Number(row.dataset.id) before arithmetic, or module 2's coercion bug comes back.

Classes

element.classList.add('paid');
element.classList.remove('paid');
element.classList.contains('paid');
element.classList.toggle('paid');

toggle returns true if the class is now present, false if removed — occasionally useful.

Prefer changing classes to setting styles. element.style.color = 'red' works and scatters design decisions through your JavaScript; a class keeps them in the stylesheet where they can be themed and overridden.

Making and removing

const item = document.createElement('li');
item.className = 'order';
item.textContent = 'Vikram';

document.querySelector('#orders').append(item);
item.remove();

createElement makes a node that is not yet in the document — nothing appears until you append it. append takes several nodes or strings; remove takes the node out.

Method Does
append(...) Add at the end. Accepts strings and multiple nodes.
prepend(...) Add at the start.
before(...) / after(...) Insert as a sibling.
replaceWith(...) Swap it out.
remove() Take it out.

Check your work

querySelector returns the first match or null; querySelectorAll returns all of them, or an empty list. Neither throws when nothing matches.

getElementsByClassName is live. Appending a matching element changes the collection you already have, from 3 to 4, while a querySelectorAll result stays at 3. Removing in a for loop over a live collection skips every other element.

A NodeList has forEach but not map. Array.isArray is false. Spread it to get a real array.

textContent escapes markup, so '<b>bold</b>' displays as those exact characters.

innerHTML will not run an inserted <script> tag, but will run an event handler attribute — <img src=x onerror="..."> executes. That is why "script tags do not run" is not a reason to trust innerHTML with user input.

input.value is what the user has now; getAttribute('value') is what the HTML started with. After setAttribute('value', 'initial') and input.value = 'typed', they read typed and initial.

dataset.id is always a string. Convert before arithmetic.

createElement alone shows nothing — the node is not in the document until appended.

Practice

  1. Select the same element four ways — by id, class, tag and a CSS selector — and confirm you get the same node.
  2. Select something that does not exist with both querySelector and querySelectorAll, and note the two different "nothing" answers. Then use the null and read the error.
  3. Prove live versus static. Take both kinds of list of .order, append a new matching element, and print both lengths.
  4. Write the skipping bug. Loop a live collection with for and remove() each item. Count what is left. Then do the same with querySelectorAll and compare.
  5. Confirm a NodeList has no map, then spread it and map it to an array of text.
  6. Scope a querySelector to one row rather than the document, and explain when that matters.
  7. Set textContent to '<b>bold</b>' and confirm it displays literally.
  8. Prove the innerHTML risk. Insert a <script> tag and confirm nothing happens. Then insert <img src=x onerror="alert(1)"> and watch it fire. Say out loud why the first result does not make the second one safe.
  9. Set an attribute and a property of the same name on an input and print both.
  10. Add a data-id, read it through dataset, and confirm its type.
  11. Use classList to add, check, toggle and remove a class.
  12. Harder. Given an array of order objects, render them as list items with data-id attributes — once with innerHTML and a map/join, and once with createElement and append. Then make one of the customer names <img src=x onerror="alert(1)"> and run both. Only one version is safe; know which, and why.

Next: events — making the page respond to the person using it.

Stuck on this lesson?

Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.

About the internship