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
- 2. Querying Elements
- 3. Traversing the Tree
- 4. Creating and Inserting Elements
- 5. Reading and Writing Content
- 6. Attributes, Classes, and Styles
- 7. Events
- 8. Event Delegation
- 9. Forms
- 10. Observers
- 11. When to Use a Framework Instead
- 12. References
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
- MDN DOM — https://developer.mozilla.org/en-US/docs/Web/API/Document_Object_Model
- MDN Event reference — https://developer.mozilla.org/en-US/docs/Web/Events
- MDN IntersectionObserver — https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserver
- MDN MutationObserver — https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver
- MDN FormData — https://developer.mozilla.org/en-US/docs/Web/API/FormData
- See also: javascript-bom-modules-and-modern-features