Skip to main content
Adapted, not copied

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 textContent for plain text (safe by default) and be cautious with innerHTML when the content isn't fully trusted.
  • Prefer live DOM properties (.checked, .value) over getAttribute for standard, interactive attributes; use dataset/classList for custom data and CSS classes.

Try it yourself

  1. Given a <ul id="tasks"> with several <li> items, write code that adds a done class to every item whose text includes the word "Review."
  2. 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.
  3. Explain why setting .innerHTML to a string containing a value typed by another user is risky, and what you'd use instead.
Hints
  1. document.querySelectorAll('#tasks li').forEach(li => { if (li.textContent.includes('Review')) li.classList.add('done'); });
  2. while (el.firstChild) el.removeChild(el.firstChild); — removing repeatedly from the front avoids re-indexing issues.
  3. Untrusted text could contain a <script> tag or an event-handler attribute, letting an attacker run arbitrary JavaScript in your page — use textContent instead, 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.

 

🌟 Join the JavaScript360 community

Connect with fellow JS developers, and get free interview prep, React challenges, and DSA guides.

Join on WhatsApp →