Original notes for JavaScript360.org — topic order inspired by Eloquent JavaScript (4th ed.) by Marijn Haverbeke. See the full attribution note.
Chapter 14: The Document Object Model
Estimated time: ~40 minutes · Chapter type: Concept
What you'll learn
- How a page's HTML becomes a tree of JavaScript objects
- Finding, creating, and removing elements
- The difference between attributes and properties
- Basic layout and styling from code
Document structure
When a browser loads HTML, it doesn't keep the raw text around — it parses it into a tree of objects called the Document Object Model (DOM), and that tree is what JavaScript actually interacts with. Given this HTML:
<body>
<h1>Task List</h1>
<ul id="tasks">
<li>Write notes</li>
<li>Review code</li>
</ul>
</body>
...the resulting tree looks roughly like:
Every HTML tag becomes an element node; the text inside becomes a text node, a child of that element. This tree is exactly what you're navigating and modifying whenever you write DOM code.
Trees, briefly
A tree is a data structure where each node has exactly one parent (except a single root) and any number of children — the same shape you'd use to represent a file system's folders, or an org chart. The DOM is one big tree rooted at document, and most DOM APIs are really just tree-traversal or tree-editing operations dressed up in web-specific names.
Moving through the tree
Every node exposes relationships to its neighbors:
const list = document.getElementById('tasks');
list.parentNode; // the <body> element
list.children; // an HTMLCollection of the two <li> elements
list.firstElementChild; // the first <li>
list.children[0].nextElementSibling; // the second <li>
Finding elements
getElementById is fast and specific; querySelector/querySelectorAll accept full CSS selectors and are what you'll reach for most often in modern code:
document.getElementById('tasks'); // one element, by id
document.querySelector('.task.done'); // first match for a CSS selector
document.querySelectorAll('li'); // a NodeList of every <li> on the page
querySelectorAll returns a static snapshot (a NodeList), which supports forEach directly:
document.querySelectorAll('li').forEach((item) => console.log(item.textContent));
Changing the document
textContent reads or writes an element's text (safely — it never interprets its input as HTML); innerHTML reads or writes raw HTML, which is powerful but risky if the content ever comes from an untrusted source (a classic cross-site-scripting, or XSS, vulnerability):
const heading = document.querySelector('h1');
heading.textContent = 'Updated Task List'; // safe: always treated as plain text
// heading.innerHTML = userInput; // dangerous if userInput isn't sanitized — avoid
Creating and removing nodes
const newItem = document.createElement('li');
newItem.textContent = 'Ship the release';
const list = document.getElementById('tasks');
list.appendChild(newItem); // add to the end
list.insertBefore(newItem, list.firstElementChild); // add to the start
newItem.remove(); // remove it again
A common pattern for adding several children at once is to build them off-screen first, in a DocumentFragment, and append the whole batch in one operation — this avoids forcing the browser to re-render the page after every single insertion:
const fragment = document.createDocumentFragment();
['Write tests', 'Deploy', 'Celebrate'].forEach((text) => {
const li = document.createElement('li');
li.textContent = text;
fragment.appendChild(li);
});
list.appendChild(fragment); // one DOM update instead of three
Attributes vs. properties
This trips up a lot of people: an HTML attribute (what's written in the markup, always a string) and the corresponding JavaScript property (a live value on the element object) are related but not always identical.
const checkbox = document.querySelector('input[type=checkbox]');
checkbox.getAttribute('checked'); // reflects the initial HTML — doesn't update as the user clicks
checkbox.checked; // the live, current state — this is what you want to read
For most standard properties (value, checked, id, className) the property is what you should use in code; getAttribute/setAttribute matter most for custom, non-standard attributes (data-* attributes especially) that don't have a dedicated property:
const card = document.querySelector('.card');
card.dataset.userId; // reads "data-user-id" — the dataset API camelCases attribute names automatically
classList is the standard way to add, remove, or toggle CSS classes without manually string-splitting className:
const item = document.querySelector('li');
item.classList.add('done');
item.classList.remove('pending');
item.classList.toggle('highlighted');
item.classList.contains('done'); // true
Layout, briefly
Once the DOM tree exists, the browser computes where each element actually appears on screen — its layout. You can read the results of that computation from JavaScript:
const box = document.querySelector('.card');
box.offsetWidth; // rendered width in pixels, including padding and border
box.getBoundingClientRect(); // { top, left, width, height, ... } relative to the viewport
Reading layout properties like these forces the browser to finish any pending layout calculation immediately (sometimes called a "forced reflow") — doing this repeatedly in a tight loop is a well-known performance trap, so batch your reads separately from your writes when working with many elements at once.
Styling
You can set inline styles directly, though in most real applications you'll toggle CSS classes (as above) and let a stylesheet own the actual visual rules — it keeps presentation logic out of your JavaScript:
const banner = document.querySelector('.banner');
banner.style.backgroundColor = 'darkred';
banner.style.display = 'none'; // a common way to hide an element
Key takeaways
- The DOM is a tree of node objects the browser builds from your HTML; JavaScript reads and edits that tree, and the browser re-renders in response.
querySelector/querySelectorAll(CSS-selector based) are the modern, flexible way to find elements.- Use
textContentfor plain text (safe by default) and be cautious withinnerHTMLwhen the content isn't fully trusted. - Prefer live DOM properties (
.checked,.value) overgetAttributefor standard, interactive attributes; usedataset/classListfor custom data and CSS classes.
Try it yourself
- Given a
<ul id="tasks">with several<li>items, write code that adds adoneclass to every item whose text includes the word "Review." - Write a function
clearList(id)that removes every child from the element with the given id, without rebuilding the whole element from an HTML string. - Explain why setting
.innerHTMLto a string containing a value typed by another user is risky, and what you'd use instead.
Hints
document.querySelectorAll('#tasks li').forEach(li => { if (li.textContent.includes('Review')) li.classList.add('done'); });while (el.firstChild) el.removeChild(el.firstChild);— removing repeatedly from the front avoids re-indexing issues.- Untrusted text could contain a
<script>tag or an event-handler attribute, letting an attacker run arbitrary JavaScript in your page — usetextContentinstead, which never parses its input as markup.
Summary
The DOM is the bridge between your JavaScript and everything the user actually sees — every dynamic web page you've ever used is, underneath, a script reading and rewriting this same tree structure. The next natural step is making that tree respond to what the user does, which is exactly what events are for.