B14 · Part 1 · Beginner
DOM & events
The DOM is a live tree of objects — and JS can poke every branch of it.
12On this page
- Selecting elements
- Reading and changing content
- Creating, appending, removing
- Walking the tree — traversal properties
- HTMLCollection vs NodeList — one of them is alive
- Batch inserts with DocumentFragment
- Events — addEventListener and the event object
- preventDefault — stopping the browser's own reaction
- Keyboard events — key vs code
- removeEventListener — and the function-identity trap
- Listener options: once, passive, and signal
- Forms and input values
Everything below runs against a real, live sandbox on this
page, not a simulation — the buttons genuinely call
querySelector, classList,
appendChild and friends against the little page snippet
right above them. Push every button before reading on; watching it
happen is most of the lesson.
Sandbox page
Click a button below to mutate me.
- Item 1
- Item 2
Every click below is logged with the exact DOM call that ran.
Selecting elements
| Call | Returns |
|---|---|
document.getElementById(id) | one element, or null — no # prefix |
document.querySelector(css) | the first match for any CSS selector, or null |
document.querySelectorAll(css) | a NodeList of every match — not a real array, but it has .forEach |
document.getElementById("de-text"); // exact id match
document.querySelector("#de-text"); // same element, CSS-selector syntax
document.querySelector(".btn"); // the FIRST element with class "btn"
document.querySelectorAll(".btn"); // every element with class "btn"
Rule
querySelector/
querySelectorAll take real CSS selectors, so anything you
can write in a stylesheet works here too —
"ul li:last-child", "[data-active]",
"input[type=email]". That flexibility is why they've
mostly replaced the older, narrower
getElementsByClassName/getElementsByTagName.
Reading and changing content
| Property | Reads/writes | Watch out for |
|---|---|---|
el.textContent | plain text only | the safe default — never parses HTML |
el.innerHTML | markup, parsed as HTML | user-supplied text through here is an XSS hole — see the security chapter |
el.getAttribute(name) / setAttribute(name, v) | any HTML attribute, always as a string | use for custom/data-* attributes |
el.classList | .add(), .remove(), .toggle(), .contains() | the modern way to manage classes — no manual string splitting |
el.textContent = "hello <b>there</b>"; // literal text — tags show up as text, not bold
el.innerHTML = "hello <b>there</b>"; // actually renders as bold
el.setAttribute("data-user-id", "42");
el.getAttribute("data-user-id"); // "42" — always a string, even for numbers
el.classList.add("active");
el.classList.toggle("open"); // on if it was off, off if it was on
el.classList.contains("active"); // true
Creating, appending, removing
const li = document.createElement("li"); // exists only in memory so far
li.textContent = "New item";
listEl.appendChild(li); // now it's actually in the page
listEl.removeChild(li); // gone from the page (still exists in memory until GC'd)
li.remove(); // modern shorthand — no need to know the parent
⚠ appendChild moves, it doesn't copy
If
li is already somewhere in the page and you
appendChild it again elsewhere, it's relocated,
not duplicated — an element can only exist at one spot in the tree at
a time. Need it in two places? Use
el.cloneNode(true) (the true means "deep
clone, children included") and append the clone.
Walking the tree — traversal properties
| Property | Gives you |
|---|---|
el.parentElement | the parent, or null at the top |
el.children | element children only — text nodes excluded, unlike el.childNodes |
el.nextElementSibling / previousElementSibling | the next/previous element at the same level, skipping whitespace text nodes |
el.closest(selector) | the nearest ancestor (including el itself) matching a CSS selector, or null |
function handleClick(event) {
const row = event.target.closest("tr"); // works whether they clicked text, an icon, or the row
if (!row) return; // clicked something outside any row
console.log("row id:", row.dataset.id);
}
table.addEventListener("click", handleClick);
closest() is what makes delegation practical for
anything nested — the click target is rarely the element you actually
care about (more often an icon or a span inside it), and
closest() walks upward from wherever the click landed
until it finds the ancestor that matters.
HTMLCollection vs NodeList — one of them is alive
const live = document.getElementsByClassName("item"); // HTMLCollection — LIVE
const frozen = document.querySelectorAll(".item"); // NodeList — static
console.log(live.length, frozen.length); // 2 2
listEl.appendChild(document.createElement("li"));
console.log(live.length, frozen.length); // what happens?
3 2 — live is a live view: it re-counts the
DOM every time you touch .length or index into it,
because getElementsByClassName/getElementsByTagName
return an HTMLCollection. querySelectorAll
always returns a static NodeList — a snapshot taken the
moment it ran, unaffected by later DOM changes. A live collection is a
classic footgun inside a loop: removing an element while iterating a
live HTMLCollection shifts every later index down by one
and silently skips an element. Convert one to a real array first
(Array.from(live)) before mutating while iterating.
Batch inserts with DocumentFragment
const fragment = document.createDocumentFragment();
for (const item of items) {
const li = document.createElement("li");
li.textContent = item;
fragment.appendChild(li); // appending to the fragment, not the live page yet
}
listEl.appendChild(fragment); // ONE reflow, however many items were added
Rule
listEl.appendChild(li)
inside a loop of 200 items makes the browser consider a layout
recalculation up to 200 times. A DocumentFragment is an
invisible, parent-less container — building the whole subtree inside
it first and appending it once costs exactly one reflow, no matter how
many children it holds. It's also emptied automatically once appended,
so there's nothing left to clean up afterward.
Events — addEventListener and the event object
button.addEventListener("click", function (event) {
console.log(event.type); // "click"
console.log(event.target); // the exact element that was clicked
console.log(event.currentTarget); // the element the LISTENER is attached to
});
target and currentTarget only differ when
events bubble — a click starts at the exact element you tapped
and travels upward through every ancestor that's listening.
target stays fixed at where it started;
currentTarget is always whichever element's listener is
currently running. That bubbling is what makes event delegation work:
put one listener on a parent list instead of one on every
item, and check event.target inside it to see which item
was actually clicked.
outer.addEventListener("click", () => console.log("outer"));
middle.addEventListener("click", () => console.log("middle"));
inner.addEventListener("click", () => console.log("inner"));
// inner is nested inside middle, which is nested inside outer
inner.click(); // simulates a real click landing on inner
Dry run: the bubble path from a click on inner
| Order fired | Listener running | event.target | event.currentTarget |
|---|---|---|---|
| 1st | inner | inner | inner |
| 2nd | middle | inner | middle |
| 3rd | outer | inner | outer |
target never changes — it's fixed at the element the click
actually landed on. currentTarget changes on every row,
because bubbling calls each ancestor's listener in turn, innermost
first, and currentTarget is always whichever one is
running right now.
preventDefault — stopping the browser's own reaction
Some elements have a built-in behavior for certain events — a form
submits and reloads the page, an <a> navigates.
event.preventDefault() cancels that specific
default without stopping the event from continuing to bubble or
running your own handler.
form.addEventListener("submit", function (event) {
event.preventDefault(); // stop the page reload
const data = new FormData(form); // now handle it yourself — fetch(), validation, etc.
});
Rule
preventDefault() stops the
browser's built-in reaction. stopPropagation() stops the
event from bubbling further up the tree. They solve two different
problems and it's common to need only one of them.
Keyboard events — key vs code
input.addEventListener("keydown", function (event) {
console.log(event.key); // "a", "A", "Enter", "Shift" — what the key actually produced
console.log(event.code); // "KeyA", "KeyA", "Enter", "ShiftLeft" — the physical key, layout-independent
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
submitForm();
}
});
Use .key for anything about what the user meant to type
— checking for "Enter", "Escape", or a specific letter — it already
accounts for Shift and the user's keyboard layout. Use
.code only for physical-position logic, like WASD game
controls, where you want the same key regardless of what character it
produces on a different layout.
removeEventListener — and the function-identity trap
function onClick() { console.log("clicked"); }
button.addEventListener("click", onClick);
button.removeEventListener("click", onClick); // works — same function reference
button.addEventListener("click", () => console.log("clicked"));
button.removeEventListener("click", () => console.log("clicked")); // does NOTHING
⚠ removeEventListener needs the exact same function reference
Two arrow functions with identical bodies are still two different
function objects —
removeEventListener compares
by reference, not by what the code says, so the second call above
removes nothing and the original listener keeps firing forever. Store
the handler in a named variable (or a class field) whenever it will
need to be removed later.
Listener options: once, passive, and signal
button.addEventListener("click", handler, { once: true }); // auto-removes itself after firing once
list.addEventListener("touchstart", handler, { passive: true }); // promises never to call preventDefault — lets the browser scroll immediately
const controller = new AbortController();
button.addEventListener("click", handler, { signal: controller.signal });
input.addEventListener("input", handler, { signal: controller.signal });
// ... later, remove BOTH listeners in one call:
controller.abort();
signal is the newest of the three, and often the best
answer to "clean up every listener a component added" — one shared
AbortController across every addEventListener
call removes all of them the moment .abort() runs, no
manual bookkeeping of which handler needs which element.
passive: true matters specifically for scroll-blocking
events (touchstart, wheel) — without it, the
browser has to wait and see whether the handler calls
preventDefault() before it can start scrolling, a
measurable jank source on mobile.
Forms and input values
input.value; // the current text — always a string, even for type="number"
input.value = ""; // clearing it programmatically
checkbox.checked; // boolean — .value on a checkbox is NOT what's checked
select.value; // the selected <option>'s value
input.addEventListener("input", e => console.log(e.target.value)); // fires on every keystroke
input.addEventListener("change", e => console.log(e.target.value)); // fires once, on blur/commit
input vs change trips a lot of people up:
input is for "react live, as they type" (a character
counter, live search); change is for "react once they're
done" (a select dropdown, a checkbox, a field that loses focus).
Interview answer, one sentence
"The DOM is a live tree you query and change; events travel down in the capture phase and back up in the bubble phase, which is what lets one listener on a parent handle every child (delegation), and removeEventListener only works with the same function reference you added."
Before you move on
- Pick the right selector method (
getElementById,querySelector,querySelectorAll) for a given task, and say what each returns when nothing matches. - Explain why
textContentis the safe default and wheninnerHTMLis genuinely needed. - Trace a click's bubble path through three nested listeners, saying what
targetandcurrentTargetare at each one. - Explain why removing a listener added as an anonymous arrow silently fails, and how to write it so it can actually be removed.
- Say when to reach for
DocumentFragmentinstead of appending in a loop, and why it changes the reflow count.
Opens in the editor: write it, run it, and check it against real tests.
All 35 for this level →Finished reading?
0 of 41 chapters read in this series