JavaScript DOM


  • Description: The Document Object Model — querying, traversing, creating, and mutating elements; event handling and delegation; forms; and the modern alternatives to manual DOM work.
  • My Notion Note ID: K2A-F4-7
  • Created: 2018-03-23
  • Updated: 2026-05-17
  • License: Reuse is very welcome. Please credit Yu Zhang and link back to the original on yuzhang.io

Table of Contents


1. What the DOM Is

The DOM (Document Object Model) is the browser's in-memory representation of the parsed HTML document — a tree of node objects:

Document
  └─ <html>
       ├─ <head>
       │    └─ <title>
       └─ <body>
            ├─ <h1>
            └─ <p>

Every node is an object with properties and methods. The DOM is live — mutating the tree changes what the user sees. Different from React's virtual DOM, which is a JS copy you reconcile against the real one.

Node types (most common):

Node type nodeType Example
ELEMENT_NODE 1 <div>, <p>
TEXT_NODE 3 text between tags
COMMENT_NODE 8 <!-- -->
DOCUMENT_NODE 9 document
DOCUMENT_FRAGMENT_NODE 11 detached subtree, shadow root

2. Querying Elements

document.getElementById('main');                  // one element by id (no '#')
document.getElementsByClassName('item');           // HTMLCollection (live)
document.getElementsByTagName('p');                // HTMLCollection (live)

document.querySelector('.item.active');            // first match for a CSS selector
document.querySelectorAll('ul > li');               // NodeList (static)

Prefer querySelector / querySelectorAll for everything — same CSS selectors you already know.

Method Returns Live?
getElementById single Element or null n/a
getElementsByClassName HTMLCollection Live — updates as DOM changes
getElementsByTagName HTMLCollection Live
querySelector Element or null n/a
querySelectorAll NodeList Static snapshot

HTMLCollection (live) gotcha:

const items = document.getElementsByClassName('item');
items.length;     // 3
container.appendChild(newItem);
items.length;     // 4 — already updated

NodeList from querySelectorAll doesn't update. Iterate with forEach or for…of:

document.querySelectorAll('li').forEach(li => /* ... */);
for (const li of document.querySelectorAll('li')) { /* ... */ }

3. Traversing the Tree

el.parentElement;       // parent (Element only — skip text/comment parents)
el.parentNode;          // parent (Element, Document, or DocumentFragment)
el.children;            // Element children (HTMLCollection)
el.childNodes;          // all children including text/comment (NodeList)
el.firstElementChild;
el.lastElementChild;
el.previousElementSibling;
el.nextElementSibling;

el.closest('.card');     // walk up — first ancestor (or self) matching selector
el.matches('.active');   // does this element match the selector?
el.contains(other);      // is `other` a descendant of `el`?

closest is the workhorse for event delegation — see § 8.

4. Creating and Inserting Elements

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

list.appendChild(li);                   // append at end (single node only)
list.append(li, anotherLi, 'text');      // appends nodes AND strings; many at once
list.prepend(li);                        // at start

beforeEl.before(li);                     // insert before sibling
afterEl.after(li);                       // after sibling

oldEl.replaceWith(newEl);                 // replace
li.remove();                              // detach

const clone = el.cloneNode(true);         // deep copy (false = shallow)

Bulk-insert HTML strings (faster, but escape user input first):

el.insertAdjacentHTML('beforeend', '<li>One</li><li>Two</li>');

Positions: beforebegin, afterbegin, beforeend, afterend.

4.1 Document Fragments

Build a subtree off-document, insert once. Cheaper than many individual appends because layout doesn't recompute on each.

const frag = document.createDocumentFragment();
for (const item of data) {
  const li = document.createElement('li');
  li.textContent = item;
  frag.appendChild(li);
}
list.appendChild(frag);    // one layout pass

5. Reading and Writing Content

el.textContent;        // text only — all descendants concatenated
el.innerText;          // similar, but layout-aware (slower, respects styling)
el.innerHTML;          // serialised HTML

el.textContent = 'Hello';     // SAFE — writes as text
el.innerHTML = '<b>Hello</b>'; // parses HTML — XSS RISK with untrusted input

innerHTML with user input is the canonical XSS vector. Either escape, sanitise (DOMPurify), or stick to textContent. Frameworks (React, Vue, Svelte) escape by default.

For attribute-shaped trusted templating, setHTML() (in some browsers) and the Trusted Types API offer defense in depth.

6. Attributes, Classes, and Styles

6.1 Attributes vs Properties

el.getAttribute('href');          // attribute (string value as in HTML)
el.setAttribute('href', '/x');
el.removeAttribute('href');
el.hasAttribute('disabled');

el.href;                          // property (resolved, e.g. absolute URL)
el.value;                         // property (live state of an input)

For most things use the property (el.id, el.className, el.value). Use getAttribute/setAttribute for non-standard attributes, attributes that differ from the property (href returns absolute URL via property), or when you specifically need the HTML state.

Boolean attributes (disabled, checked, hidden):

button.disabled = true;            // best — set the property
button.setAttribute('disabled', '');  // also works
button.removeAttribute('disabled');
// button.setAttribute('disabled', 'false');  // WRONG — still disabled (presence matters)

6.2 Classes

el.classList.add('active', 'highlight');
el.classList.remove('active');
el.classList.toggle('open');
el.classList.toggle('open', shouldBeOpen);   // explicit boolean — set/unset
el.classList.contains('active');
el.classList.replace('old', 'new');

classList is much nicer than the older el.className string mangling.

6.3 Inline Styles

el.style.color = 'red';
el.style.backgroundColor = 'blue';     // camelCase property names
el.style.setProperty('--accent', 'oklch(70% 0.2 30)');   // CSS custom property
el.style.cssText = 'color: red; background: blue;';      // bulk

Better default: toggle classes, keep declarations in CSS. Reach for inline styles only for dynamic values (positions, custom property values from JS state).

6.4 Data Attributes

<div id="row" data-user-id="42" data-status="active"></div>
row.dataset.userId;       // '42' (camelCase from kebab)
row.dataset.status;       // 'active'
row.dataset.userId = '99';

data-* attributes are the official way to attach app data to elements without polluting the DOM with custom attributes.

6.5 Geometry

el.getBoundingClientRect();    // { top, left, right, bottom, width, height } in viewport coords
el.offsetWidth;                  // border-box width (integer)
el.clientWidth;                  // content + padding (integer)
el.scrollWidth;                  // full content width including overflow
el.scrollTop;                    // scroll offset
el.offsetTop;                    // offset relative to offsetParent

getBoundingClientRect() is the modern, fractional-pixel accurate version. Layout values force a reflow — accessing them inside a tight loop is a performance trap (read all values first, then write all updates).

7. Events

button.addEventListener('click', (event) => {
  // event.target — element that fired
  // event.currentTarget — element the listener is on
  // event.preventDefault() — cancel default behaviour
  // event.stopPropagation() — stop bubbling
});

button.addEventListener('click', handler, {
  once: true,                    // remove after first fire
  passive: true,                 // promise not to call preventDefault (touch/scroll perf)
  capture: true,                 // listen during capture phase, not bubble
  signal: ctrl.signal,           // remove via AbortController (modern, easy cleanup)
});

button.removeEventListener('click', handler);

7.1 The Event Object

Common properties:

Property Description
type Event name ('click', 'submit', …).
target What dispatched the event.
currentTarget Element the listener is on.
eventPhase 1 capture, 2 target, 3 bubble.
defaultPrevented Has preventDefault() been called?
timeStamp High-resolution time of event creation.
isTrusted True if user-triggered, false if dispatched from JS.

Mouse: clientX/clientY, pageX/pageY, button, buttons, ctrlKey, shiftKey, altKey, metaKey.

Keyboard: key (logical, e.g. 'a', 'Enter', 'ArrowUp'), code (physical layout, 'KeyA'), repeat.

Touch: event.touches[i].clientX. Modern pointer events (pointerdown, pointermove) cover mouse + touch + pen uniformly.

7.2 Capture, Target, Bubble

       (capture phase ↓)
window → document → html → body → div → span → CLICKED
       (bubble phase ↑)

By default, listeners fire during the target and bubble phases. Add { capture: true } to listen during capture. Most apps only care about bubble.

stopPropagation() halts further travel; stopImmediatePropagation() also prevents other listeners on the same element from firing.

preventDefault() cancels the browser's default action (form submit, link navigation, text selection). Doesn't stop propagation.

7.3 Custom Events

const ev = new CustomEvent('user-loaded', { detail: { user } });
el.dispatchEvent(ev);

el.addEventListener('user-loaded', (e) => {
  console.log(e.detail.user);
});

Useful for component-style decoupling — fire an event, let unknown listeners react.

8. Event Delegation

Attach one listener on a common ancestor instead of one per item. Cheaper, works for elements added later.

list.addEventListener('click', (e) => {
  const item = e.target.closest('.item');
  if (!item || !list.contains(item)) return;
  const id = item.dataset.id;
  // ...
});

closest() walks up looking for the right element. Combined with contains() to scope to within the listener's owner, it's the workhorse pattern for dynamic lists, menus, tables.

9. Forms

const form = document.querySelector('form');

form.addEventListener('submit', (e) => {
  e.preventDefault();
  const data = new FormData(form);          // multipart-style data
  data.get('email');                          // single field
  data.getAll('hobbies');                     // multi-select / checkbox
  const obj = Object.fromEntries(data);       // plain object — only the LAST value is kept for repeated names; use data.getAll(name) for multi-value fields

  fetch('/api', { method: 'POST', body: data });
});

<input> access:

const email = form.elements.email;          // by name
email.value;
email.checked;                                // for checkbox/radio
email.disabled = true;
email.focus();
email.select();
email.validity.valid;                          // browser-built-in validation
email.setCustomValidity('Email must end in @example.com');
form.checkValidity();
form.reportValidity();

Events: submit, reset, change (commit), input (every keystroke), focus, blur, focusin, focusout.

10. Observers

Browser APIs that watch the DOM and call back when something changes — replace manual polling.

10.1 MutationObserver

const obs = new MutationObserver((mutations) => {
  for (const m of mutations) {
    // m.type: 'childList' | 'attributes' | 'characterData'
    // m.addedNodes, m.removedNodes, m.attributeName, ...
  }
});

obs.observe(target, {
  childList: true,
  subtree: true,
  attributes: true,
});

obs.disconnect();

10.2 IntersectionObserver

When does an element enter/leave the viewport?

const io = new IntersectionObserver((entries) => {
  for (const entry of entries) {
    if (entry.isIntersecting) {
      entry.target.classList.add('visible');
      io.unobserve(entry.target);     // run once
    }
  }
}, { rootMargin: '0px 0px -10% 0px', threshold: 0 });

document.querySelectorAll('.lazy').forEach(el => io.observe(el));

Standard for lazy-loading images, fade-in animations, infinite scroll, ad impression tracking.

10.3 ResizeObserver

const ro = new ResizeObserver((entries) => {
  for (const entry of entries) {
    const { width, height } = entry.contentRect;
    // ...
  }
});
ro.observe(panel);

Replaces window-resize-and-loop. Fires only when the watched element actually resizes.

11. When to Use a Framework Instead

Manual DOM is fine for small interactive bits in a static page — a menu toggle, a copy-to-clipboard button.

Reach for a framework (React, Vue, Svelte, Solid) once you have:

  • State that drives multiple DOM areas — manual sync becomes a mess.
  • Lists that grow/shrink/reorder — diffing logic by hand is tedious.
  • Forms with cross-field validation and conditional fields.
  • Anything resembling an SPA.

The DOM API is still underneath everything — frameworks are additive, not replacements. Knowing the DOM helps debug what frameworks emit and to drop down for the 5% that's faster done by hand.

12. References