The DOM in Depth

Selecting and traversing elements, creating/modifying nodes programmatically, and event delegation.

The DOM as a tree

The DOM (Document Object Model) is the browser's live, in-memory representation of an HTML page as a tree of objects — every element, attribute, and piece of text is a node in that tree, and JavaScript reads and mutates the page by reading and mutating that tree. Changing the DOM is what makes the page visibly update; the original HTML source never changes.

HTML
<div id="app">
  <h1 class="title">Hello</h1>
  <ul class="list">
    <li>One</li>
    <li>Two</li>
  </ul>
</div>

This produces a tree: div#app has two children (h1.title and ul.list), and ul.list itself has two li children — exactly the nesting structure of the HTML, now available to JavaScript as live objects.

Selecting elements

document.querySelector and document.querySelectorAll accept any real CSS selector, which makes them the modern, general-purpose way to find elements — far more flexible than the older, selector-specific methods they've largely replaced:

Javascript
const app = document.querySelector("#app");           // first match, or null
const title = document.querySelector(".title");
const items = document.querySelectorAll(".list li");  // a NodeList of every match

console.log(items.length);   // 2
items.forEach(item => console.log(item.textContent));
// One
// Two

Older, more specific selection methods still work and are slightly faster for their exact case, but querySelector/querySelectorAll are the right default for new code:

Javascript
document.getElementById("app");             // by id only, no # prefix
document.getElementsByClassName("list");     // live HTMLCollection, by class only
document.getElementsByTagName("li");         // live HTMLCollection, by tag only

The key practical difference: querySelectorAll returns a static NodeList (a snapshot at the moment it was called), while getElementsByClassName/getElementsByTagName return a live HTMLCollection that automatically updates if the DOM changes afterward — a distinction that occasionally matters and is a common source of subtle bugs when someone expects one behavior but got the other.

Traversing the tree

Every DOM node exposes properties for moving to its relatives:

Javascript
const list = document.querySelector(".list");

console.log(list.parentElement);        // div#app
console.log(list.children);              // HTMLCollection [li, li]
console.log(list.firstElementChild);     // the first <li>
console.log(list.lastElementChild);      // the second <li>

const firstItem = list.firstElementChild;
console.log(firstItem.nextElementSibling);   // the second <li>
console.log(firstItem.previousElementSibling); // null — it's the first child

Prefer the ...Element... variants (children, firstElementChild, nextElementSibling) over their plain counterparts (childNodes, firstChild, nextSibling) for everyday DOM work — the plain versions also include text nodes (like the whitespace between tags in your source HTML), which is rarely what you actually want.

Creating and modifying elements programmatically

Building UI dynamically means creating nodes in memory, configuring them, then attaching them to the visible tree:

Javascript
const list = document.querySelector(".list");

const newItem = document.createElement("li");
newItem.textContent = "Three";
newItem.classList.add("highlight");
list.appendChild(newItem);
Javascript
// Common element manipulation
const title = document.querySelector(".title");

title.textContent = "Updated Title";      // safe — always treated as plain text
title.innerHTML = "<em>Updated</em>";     // parses the string as HTML — see the warning below

title.classList.add("active");
title.classList.remove("hidden");
title.classList.toggle("expanded");        // adds it if absent, removes it if present

title.setAttribute("data-id", "42");
console.log(title.getAttribute("data-id")); // "42"

title.style.color = "blue";                 // inline style, for one-off cases

textContent versus innerHTML matters for more than style: innerHTML parses its argument as HTML, so inserting any untrusted, user-supplied string with innerHTML opens a cross-site scripting (XSS) hole — a malicious <script> or event-handler attribute embedded in that string can execute. textContent always treats its argument as plain text, with no parsing, and is the safe default whenever you're just displaying text.

Building several elements and inserting them in one batch, rather than one appendChild at a time straight into the live document, avoids triggering a page re-layout on every single insertion:

Javascript
const list = document.querySelector(".list");
const fragment = document.createDocumentFragment();

["Four", "Five", "Six"].forEach(text => {
  const li = document.createElement("li");
  li.textContent = text;
  fragment.appendChild(li);
});

list.appendChild(fragment);   // one single DOM update, not three

A DocumentFragment isn't part of the visible page at all — it's an in-memory container. Appending it moves all its children into the real DOM at once and leaves the (now-empty) fragment behind, which is far cheaper than three separate appendChild calls directly against list, each of which would trigger the browser to recompute layout.

Removing elements

Javascript
const item = document.querySelector(".list li");
item.remove();                       // modern, direct removal

// older equivalent, still seen in existing code:
item.parentElement.removeChild(item);

Event delegation

Attaching a separate event listener to every individual list item works, but it doesn't scale — every item added later needs its own new listener, and a long list means many separate listener objects. Event delegation exploits event bubbling (an event fires first on the exact element clicked, then "bubbles" up through each of its ancestors in turn) by attaching one listener to a shared parent, then checking which specific child was actually the target:

Javascript
// Fragile — misses any <li> added to the list after this code runs
document.querySelectorAll(".list li").forEach(li => {
  li.addEventListener("click", () => console.log(`Clicked: ${li.textContent}`));
});

// Delegation — one listener, works for every current AND future <li>
document.querySelector(".list").addEventListener("click", (event) => {
  const item = event.target.closest("li");
  if (!item) return;   // the click landed on the list itself, not on an <li>
  console.log(`Clicked: ${item.textContent}`);
});

event.target is the exact element that was actually clicked — which might be a <span> nested inside an <li>, not the <li> itself. .closest("li") walks up from the exact target through its ancestors until it finds one matching the selector, which is why it's the standard, robust way to find "which list item (if any) does this click actually belong to," regardless of how deeply nested the exact clicked element was.

Event delegation's real advantage shows up with dynamic content — a single listener on the list's parent, attached once, automatically covers every item added to that list afterward, with no need to re-attach anything:

Javascript
document.querySelector(".list").addEventListener("click", (event) => {
  const item = event.target.closest("li");
  if (item) console.log(`Clicked: ${item.textContent}`);
});

// Added later — the delegated listener above already covers this new item, no extra code needed
const newItem = document.createElement("li");
newItem.textContent = "Seven";
document.querySelector(".list").appendChild(newItem);

Comparing selection and traversal approaches

Task Modern approach Notes
Select any element(s) by CSS selector querySelector / querySelectorAll Static snapshot; use for almost everything
Select by id/class/tag only getElementById / getElementsByClassName / getElementsByTagName Returns a live collection — updates automatically
Move to a related element .parentElement, .children, .nextElementSibling Skips text/whitespace nodes, unlike the plain Node equivalents
Insert text safely .textContent Never parsed as HTML — safe from injected markup
Insert HTML markup .innerHTML Only with trusted, non-user-supplied content
Handle clicks on many/future items Event delegation on a shared ancestor One listener instead of one per item

Common mistakes

  • Setting .innerHTML with untrusted, user-supplied text — a classic XSS vulnerability. Use .textContent for plain text, and only ever pass trusted, sanitized markup to .innerHTML.
  • Attaching an individual listener to each item in a list that can grow — new items added later silently have no listener at all, unless the code re-runs the attachment logic for them too.
  • Assuming event.target inside a delegated listener is always the element the listener was attached to — it's the specific element that was actually clicked, which is often a descendant; .closest(selector) is what finds the intended ancestor.
  • Confusing a live HTMLCollection (from getElementsByClassName) with a static NodeList (from querySelectorAll) — iterating and mutating the DOM at the same time behaves very differently depending on which one you're holding.

Interview questions

Q: What is event delegation, and why is it preferred over attaching a listener to every individual element? It relies on event bubbling — attach one listener to a shared ancestor, and inside it use event.target (with .closest(selector)) to determine which specific descendant was actually interacted with. It scales far better than attaching a listener per item: it uses one listener instead of many, and it automatically covers elements added to the DOM later, since the listener lives on the stable parent rather than on each individual, possibly-not-yet-created child.

Q: What's the security risk of innerHTML, and how does textContent avoid it? innerHTML parses its string argument as HTML markup, so if that string includes user-supplied content containing a <script> tag or an event-handler attribute (<img onerror="...">), the browser can execute it — a cross-site scripting (XSS) vulnerability. textContent always inserts its argument as literal, unparsed text, no matter what characters it contains, so it's the safe default for displaying any content that ultimately came from a user or an external source.