Skip to the chapter
←JavaScriptGGroundworkJSJavaScript
JavaScript/Beginner/B14
14 / 41 written chapters
/

The cover
  1. B1Setup & mental model
  2. B2Execution context
  3. B3var, let & const
  4. B4Scope
  5. B5Single thread
  6. B6In the browser
  7. B7Types & values
  8. B8Strings & Math
  9. B9Operators & flow
  10. B10Functions
  11. B11Objects & arrays
  12. B12this
  13. B13Closures
  14. B14DOM & events
  15. B15Callbacks & promises
  16. B16Errors & tools
  17. B17Guided project
  18. ★Cheat page
  1. I1Scope & functions, properly
  2. I2Objects deeply
  3. I3Prototypes & OOP
  4. I4Async, properly
  5. I5Modules & tooling
  6. I6Regex & dates
  7. I7Error handling
  8. I8Real-time connections
  9. I9Offline & storage
  10. I10Browser APIs
  11. I11Modern JS
  12. ★Cheat page
  1. A1Engine & memory
  2. A2Advanced async
  3. A3Metaprogramming
  4. A4Types & data
  5. A5Patterns & architecture
  6. A6Performance
  7. A7Security
  8. A8Ecosystem
  9. A9Testing
  10. A10Observability
  11. A11Interview bank
  12. A12Build it yourself
  13. ★Cheat page

JSB14 · Part 1 · Beginner

DOM & events

The DOM is a live tree of objects — and JS can poke every branch of it.

7 min read12 sections2 exercises
14 of 41

In this chapter

  • Selecting elements
  • Reading and changing content
  • Creating, appending, removing
  • Walking the tree — traversal properties
  • HTMLCollection vs NodeList — one of them is alive

and 7 more

about 7 min
12On this page
  1. Selecting elements
  2. Reading and changing content
  3. Creating, appending, removing
  4. Walking the tree — traversal properties
  5. HTMLCollection vs NodeList — one of them is alive
  6. Batch inserts with DocumentFragment
  7. Events — addEventListener and the event object
  8. preventDefault — stopping the browser's own reaction
  9. Keyboard events — key vs code
  10. removeEventListener — and the function-identity trap
  11. Listener options: once, passive, and signal
  12. 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.

Live DOM playground

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.textContentplain text onlythe safe default — never parses HTML
el.innerHTMLmarkup, parsed as HTMLuser-supplied text through here is an XSS hole — see the security chapter
el.getAttribute(name) / setAttribute(name, v)any HTML attribute, always as a stringuse 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.parentElementthe parent, or null at the top
el.childrenelement children only — text nodes excluded, unlike el.childNodes
el.nextElementSibling / previousElementSiblingthe 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 firedListener runningevent.targetevent.currentTarget
1stinnerinnerinner
2ndmiddleinnermiddle
3rdouterinnerouter

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 textContent is the safe default and when innerHTML is genuinely needed.
  • Trace a click's bubble path through three nested listeners, saying what target and currentTarget are 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 DocumentFragment instead of appending in a loop, and why it changes the reflow count.

02Practice this chapter

Opens in the editor: write it, run it, and check it against real tests.

  • 10 testsBuild a mini event emitterbeginner
  • 9 testsWalk up to the nearest matching ancestorbeginner
All 35 for this level →
B14

Finished reading?

0 of 41 chapters read in this series

→Next stop

  1. B13Previous · B13Closures
  2. B14you are here
  3. Next · B15Callbacks, then promisesHanding work to someone else, and getting the answer back later.9 min read

01On this page

12 sections · 0%

  1. 01Selecting elements
  2. 02Reading and changing content
  3. 03Creating, appending, removing
  4. 04Walking the tree — traversal properties
  5. 05HTMLCollection vs NodeList — one of them is alive
  6. 06Batch inserts with DocumentFragment
  7. 07Events — addEventListener and the event object
  8. 08preventDefault — stopping the browser's own reaction
  9. 09Keyboard events — key vs code
  10. 10removeEventListener — and the function-identity trap
  11. 11Listener options: once, passive, and signal
  12. 12Forms and input values

[] chapters · t top