The DOM

Mental model: The DOM is not your HTML. It is a live tree of objects the browser built from your HTML, and every change you make to it is visible immediately.

Level: intermediate · about 16 minutes

You write <ul><li>Tea</li></ul> and ship it. The browser reads that text once, builds a tree of objects from it, and then throws the text away. From that moment on the tree is the truth. document is its root handle, and every method in this lesson is a way of asking that tree a question.

const doc = {
  tag: 'body',
  children: [
    { tag: 'h1', text: 'Shop', children: [] },
    { tag: 'ul', text: '', children: [
      { tag: 'li', text: 'Tea', children: [] },
      { tag: 'li', text: 'Coffee', children: [] },
    ] },
  ],
};

function walk(node, depth = 0) {
  console.log('  '.repeat(depth) + node.tag + (node.text ? ': ' + node.text : ''));
  for (const child of node.children) walk(child, depth + 1);
}

walk(doc);
// -> body
// ->   h1: Shop
// ->   ul
// ->     li: Tea
// ->     li: Coffee

A tree of plain objects, so you can see the shape before you meet the API.

The real DOM is that, with more node types and a much larger API. The DOM (Document Object Model) is the browser tree of node objects plus the methods for reading and changing it. It is not part of JavaScript. It is a set of host objects the browser hands to the language, which is why none of it exists in Node.

document
  |
  +-- html                (Element)
        |
        +-- head          (Element)
        |     +-- title   (Element)
        |           +-- "Shop"        (Text)
        |
        +-- body          (Element)
              +-- "\n  "             (Text, from indentation)
              +-- h1     (Element)
              |     +-- "Shop"        (Text)
              +-- ul     (Element)
                    +-- li (Element) --> "Tea"  (Text)
                    +-- li (Element) --> "Coffee" (Text)

Nodes versus elements

A node is anything in the tree: an element, a run of text, a comment, the document itself. An element is the subset of nodes that came from a tag. The distinction only bites you when you use an API that counts nodes where you expected it to count elements.

nodeTypeNameComes fromIs an element?
1Elementa tag such as <li>yes
3Textany characters, including the newline after a tagno
8Comment<!-- ... -->no
9Documentthe document objectno
11DocumentFragmentdocument.createDocumentFragment()no
if (typeof document === 'undefined') {
  console.log('No document here (this runs in Node), so here is what a browser prints:');
  console.log('childNodes.length -> 5   (2 elements + 3 whitespace text nodes)');
  console.log('children.length   -> 2   (elements only)');
} else {
  const ul = document.createElement('ul');
  ul.innerHTML = '\n  <li>Tea</li>\n  <li>Coffee</li>\n';
  console.log('childNodes.length ->', ul.childNodes.length); // -> 5
  console.log('children.length   ->', ul.children.length);   // -> 2
  console.log('firstChild        ->', ul.firstChild.nodeType);        // -> 3 (a Text node)
  console.log('firstElementChild ->', ul.firstElementChild.tagName);  // -> LI
}

Prettified HTML puts text nodes between your elements. Run this in the browser to see the gap.

Finding things

document.querySelector('.price');            // first match, or null
document.querySelectorAll('li.done');        // a static NodeList of all matches

const list = document.querySelector('#cart');
list.querySelector('button.remove');         // scoped: searches inside list only

document.getElementById('cart');             // fastest, but id only
document.getElementsByClassName('done');     // live HTMLCollection
document.getElementsByTagName('li');         // live HTMLCollection

Two methods cover almost every case. Both take any CSS selector.

CallReturnsMissing matchLive?
querySelector(sel)one Elementnullnot a collection
querySelectorAll(sel)NodeListempty NodeListno, a snapshot
getElementById(id)one Elementnullnot a collection
getElementsByClassName(c)HTMLCollectionempty collectionyes
getElementsByTagName(t)HTMLCollectionempty collectionyes
el.closest(sel)nearest ancestor (or el)nullnot a collection
el.matches(sel)booleanfalsenot a collection

Live versus static collections

// <ul id="list"><li>a</li></ul>
const live = document.getElementsByTagName('li');
const stat = document.querySelectorAll('li');

document.querySelector('#list').append(document.createElement('li'));

console.log(live.length, stat.length);

An HTMLCollection from getElementsBy* is a live view: it re-queries the tree whenever you read it, so it sees the new <li>. A NodeList from querySelectorAll is a snapshot taken at call time and never updates. So 2 1.

Live collection, infinite loop

const items = document
  .getElementsByClassName('done');

// items shrinks as you remove,
// so the index outruns it and
// half the elements survive
for (let i = 0; i < items.length; i++) {
  items[i].remove();
}

Static snapshot, predictable

const items = document
  .querySelectorAll('.done');

// the list was fixed the moment
// you asked for it
for (const item of items) {
  item.remove();
}

If you must iterate a live collection while changing it, copy it first with [...items]. Live collections are useful when you deliberately want a self-updating count, for example a status line showing how many items are still open.

`NodeList`
has forEach, but no map or filter. Spread it: [...nodes]
`HTMLCollection`
no iteration methods at all, and live. Spread it before you loop
both
are array-like, not arrays. Array.isArray(nodes) is false

Walking the tree

el.parentNode;            el.parentElement;
el.childNodes;            el.children;
el.firstChild;            el.firstElementChild;
el.lastChild;             el.lastElementChild;
el.nextSibling;           el.nextElementSibling;
el.previousSibling;       el.previousElementSibling;

el.closest('form');       // upward, matching a selector
el.contains(other);       // is other inside el (or el itself)?

Two parallel families of properties: one counts every node, one counts only elements.

Attributes versus properties

The HTML attribute is the initial value written in the markup. The DOM property is the current state of the object. They are set up to look identical, and then they drift apart the second a user types.

input.getAttribute('value');   // -> 'tea'    the markup, unchanged
input.value;                   // -> 'flour'  the live state

input.setAttribute('value', 'salt');
input.value;                   // -> 'flour'  still, the property has taken over

input.value = 'sugar';         // this is what you want, almost always

The user types "flour" into an input whose markup said value="tea".

CaseAttributePropertyNotes
value on an inputthe defaultthe live valueuse the property
checked on a checkboxthe defaultthe live stateuse the property
classgetAttribute('class')className / classListuse classList
href on a linkthe raw text /aboutthe absolute URLthey differ on purpose
data-*getAttribute('data-id')dataset.iduse dataset
disabledpresent or absenttrue / falsesetting ="false" still disables

dataset and classList

// <li data-product-id="42" data-in-stock="true">Tea</li>
li.dataset.productId;      // -> '42'    always a string
li.dataset.inStock;        // -> 'true'  also a string, so JSON.parse or compare
Number(li.dataset.productId); // -> 42

li.dataset.qty = 3;        // writes data-qty="3"
delete li.dataset.qty;     // removes the attribute

data-* attributes are the sanctioned place to hang your own values on an element.

function toDataset(attrs) {
  const out = {};
  for (const [name, value] of Object.entries(attrs)) {
    if (!name.startsWith('data-')) continue;
    const key = name
      .slice(5)
      .replace(/-([a-z0-9])/g, (_, ch) => ch.toUpperCase());
    out[key] = value;
  }
  return out;
}

console.log(toDataset({ 'data-product-id': '42', 'data-in-stock': 'true', class: 'row' }));
// -> { productId: '42', inStock: 'true' }

The dash-to-camelCase rule dataset uses, written out. This runs anywhere.

el.classList.add('active', 'visible');
el.classList.remove('pending');
el.classList.toggle('open');            // returns the new state
el.classList.toggle('open', isOpen);    // force it, no guessing
el.classList.replace('sm', 'lg');
el.classList.contains('active');        // -> true / false

el.className = 'active';                // wipes every other class, avoid

classList is a small API you will use every day.

Inline style versus classes

Inline styles

el.style.backgroundColor = 'red';
el.style.display = 'none';

// camelCase property names
// wins the specificity fight
// with your stylesheet
// scattered across your JS

A class the CSS owns

el.classList.toggle('is-error', bad);
el.hidden = true;

/* .is-error { background: red } */

// design stays in the CSS
// JS only decides state

Set state in JavaScript, appearance in CSS. Inline styles are the right tool for values only the runtime knows, such as a computed pixel position or a percentage on a progress bar, and for CSS custom properties: el.style.setProperty('--x', x + 'px').

Try it yourself

Query a tree by hand

const tree = {
  tag: 'main', attrs: {}, children: [
    { tag: 'h1', attrs: {}, children: [] },
    { tag: 'ul', attrs: { class: 'cart' }, children: [
      { tag: 'li', attrs: { 'data-id': '1' }, children: [] },
      { tag: 'li', attrs: { 'data-id': '2' }, children: [
        { tag: 'button', attrs: { class: 'remove' }, children: [] },
      ] },
    ] },
  ],
};

function select(node, tag, found = []) {
  if (node.tag === tag) found.push(node);
  for (const child of node.children) select(child, tag, found);
  return found;
}

console.log(select(tree, 'li').map((n) => n.attrs['data-id'])); // -> [ '1', '2' ]
console.log(select(tree, 'button').length);                     // -> 1
console.log(select(tree, 'table').length);                      // -> 0

Add a depth to each result. Then make select accept .classname as well as a tag name.

Implement closest

const root = { tag: 'section', parent: null, attrs: { 'data-panel': 'cart' } };
const row = { tag: 'li', parent: root, attrs: { 'data-id': '7' } };
const button = { tag: 'button', parent: row, attrs: {} };

function closest(node, attr) {
  let current = node;
  while (current) {
    if (attr in current.attrs) return current;
    current = current.parent;
  }
  return null;
}

console.log(closest(button, 'data-id').attrs);     // -> { 'data-id': '7' }
console.log(closest(button, 'data-panel').tag);    // -> section
console.log(closest(button, 'data-missing'));      // -> null

Add a matches argument so it can look for a class instead of a data attribute. Then handle the case where nothing matches.

Exercises

querySelectorAll by hand

A node is { tag, attrs, children }. Write selectAll(root, tag) returning every node whose tag matches, in document order (a node before its own children, and children before later siblings). Include the root itself if it matches. Return [] when nothing matches.

Live collection versus snapshot

Write two functions over the same fake tree. snapshot(root, tag) returns a plain array of matches, fixed at call time. liveCount(root, tag) returns an object with a length getter that re-counts the tree every time it is read. Adding a node afterwards must change liveCount(...).length but must not change the array from snapshot.

Check yourself

What does ul.children.length report for <ul>\n <li>a</li>\n <li>b</li>\n</ul>?
2 — children holds elements only, so it is 2. childNodes would be 5, because the three runs of whitespace between and around the list items are Text nodes.
A checkbox is rendered as <input type="checkbox" checked>. The user unticks it. What do you get?
getAttribute('checked') is '' and .checked is false — The attribute is the initial value from the markup and does not move when the user interacts, so it is still present (an empty string). The property tracks live state, so it is false. Read the property whenever you want to know what is true right now.
What is logged?
1 then 2 — getElementsByClassName returns a live HTMLCollection. Reading .length re-evaluates the query against the current tree, so the second read includes the div that was just given the class. querySelectorAll would have printed 1 twice.
Which is the safest way to find the row a clicked button belongs to?
btn.closest('[data-row-id]') — closest walks upward until a node matches the selector, so it keeps working when someone adds a wrapper <div> or reorders the markup. Counting parentElement hops encodes today's HTML structure into your JavaScript.

Common mistakes

  • Expecting firstChild to be an element. In formatted HTML it is nearly always a whitespace text node.
  • Looping forward over a live HTMLCollection while removing elements, so you silently skip half of them.
  • Calling .map on a NodeList. It has forEach but nothing else. Spread it first.
  • Reading getAttribute('value') and wondering why it ignores what the user typed.
  • Setting disabled="false" with setAttribute, which disables the control.
  • Assuming a querySelector typo will throw. It returns null and fails one line later.

Takeaways

  • The DOM is a live tree of objects built from your HTML, not the HTML text itself.
  • Every element is a node, but text and comments are nodes too. Prefer the element-flavoured properties.
  • querySelector and querySelectorAll cover almost everything, and the latter is a static snapshot.
  • getElementsBy* returns live collections that change under your loop. Copy with spread before mutating.
  • Attributes are the markup defaults, properties are the current state. Read the property.
  • Use dataset for your own data, classList for state, and CSS for appearance.