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);
<b>bold</b>
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
- Select the same element four ways — by id, class, tag and a CSS selector — and confirm you get the same node.
- Select something that does not exist with both
querySelectorandquerySelectorAll, and note the two different "nothing" answers. Then use thenulland read the error. - Prove live versus static. Take both kinds of list of
.order, append a new matching element, and print both lengths. - Write the skipping bug. Loop a live collection with
forandremove()each item. Count what is left. Then do the same withquerySelectorAlland compare. - Confirm a
NodeListhas nomap, then spread it and map it to an array of text. - Scope a
querySelectorto one row rather than the document, and explain when that matters. - Set
textContentto'<b>bold</b>'and confirm it displays literally. - Prove the
innerHTMLrisk. 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. - Set an attribute and a property of the same name on an input and print both.
- Add a
data-id, read it throughdataset, and confirm its type. - Use
classListto add, check, toggle and remove a class. - Harder. Given an array of order objects, render them as list items with
data-idattributes — once withinnerHTMLand amap/join, and once withcreateElementandappend. 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