The DOM: the page from JavaScript
In short: The DOM is the tree of objects through which JavaScript reads and changes the page: querySelector finds an element, textContent changes its text, and createElement and append add new ones.
Problem
The price stays "—"
The price and the stock of the product come from JavaScript. A colleague put the script at the very top "so it starts sooner". The page shows "—" instead of a price.
Console
The console says two things. console.log(price) printed null, and the next line fails with TypeError: Cannot set properties of null. The element <strong id="price"> is right there in the HTML. Why can't JavaScript find it?
The idea
The document as an object
In the HTML module you saw that the browser builds a tree from the text. JavaScript works with the same tree: to it the page is an object named document. Every element in the tree is an object with fields and methods. That is why the tree is called the DOM: the Document Object Model.
You find an element with a selector, just like in CSS:
const price = document.querySelector('#price'); // by idconst title = document.querySelector('h1'); // by tagconst first = document.querySelector('.product'); // by class: the first oneconst links = document.querySelectorAll('nav a'); // all links inside navquerySelectorwalks the tree from the top down and returns the first element that matches. With no match it returnsnull.querySelectorAllreturns all of them as a NodeList: a list withlengthandforEach, but nomaporfilter. For those, turn it into an array:[...links]. Arrays andforEachcome from the JavaScript course: arrays and lambdas.- The NodeList is a snapshot: an element added later is not in it.
null means "there is no such element", not "an empty element". The error comes after: null has no textContent, and the code fails on the next line.
The idea
When the script runs
The parser reads the HTML from the top down. When it reaches <script>, it stops, runs the script and only then goes on. In the problem the script is before <article>. When it runs, the tree has only <body>, and the price is not there yet. That is why querySelector returns null.
The fix is to make the script wait for the tree:
<head> <meta charset="utf-8"> <script src="script.js" defer></script></head>defersays: download the script right away, but run it when all of the HTML has been read. By then every element is in the tree.- The older way is a
<script>at the very bottom, just before</body>. It works for the same reason. deferonly works on a script withsrc. On a script with code between the tags it does nothing.
Try it
Fix the price
Fix the page so that it shows the price and the stock. Do not touch script.js: the problem is when it runs, not what it does.
Console
Solution
index.html<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Dobrudzha bread</title>
<link rel="stylesheet" href="style.css">
<script src="script.js" defer></script>
</head>
<body>
<article>
<h1>Dobrudzha bread</h1>
<p>Price: <strong id="price">—</strong></p>
<p id="stock">—</p>
<button type="button">Add to cart</button>
</article>
</body>
</html>
style.cssbody { font-family: system-ui, sans-serif; padding: 16px; }
button { font-size: 16px; padding: 8px 16px; }
script.jsconst price = document.querySelector('#price');
console.log(price);
price.textContent = '€1.80';
document.querySelector('#stock').textContent = 'In stock: 12';
<script src="script.js" defer></script> in <head> waits for the tree to be ready. Then #price and #stock are in it and querySelector finds them. Without defer a script in <head> runs even earlier and finds null again. An if (price) check in script.js hides the error, but the price still does not show: it guards against null, it does not fix it.
The idea
The text: textContent and innerHTML
You change the element you found through its fields. For text there are two:
price.textContent = '€1.80'; // the text as it isnote.innerHTML = 'Price: <strong>€1.80</strong>'; // the text is read as HTMLtextContentputs in text. If it contains<strong>, that shows up as characters, not as a tag.innerHTMLhands the text to the parser, which builds new elements from it. The old children of the element are gone.
innerHTML with data from a user (a name, a review, a search) is dangerous. If it holds HTML with code, that code runs in the browser of everyone who opens the page: this is an XSS attack, and the lesson "Untrusted input" is about it. So text goes in with textContent, and new elements are made with createElement (below).
After the change the browser redraws by itself. Not the whole page, only what changed.
See it
See how the page changes
On the left is the tree, on the right is the page the browser draws from it. The code finds the cart, changes its text and class and adds a new line to the list.
const cart = document.querySelector('#cart');cart.textContent = 'Cart: 1';cart.classList.add('full');const li = document.createElement('li');li.textContent = 'Milk';document.querySelector('ul').append(li);Step by step, in words
- The parser has already built the tree from the HTML. On the right is the page the browser draws from it. JavaScript sees the same tree through document.
- querySelector('#cart') walks the tree from the top and compares each element with the selector. It returns the first match: cart now points to that node. With no match it returns null.
- textContent replaces the text of the node. The browser notices the change and redraws only that part of the page. The rest stays as it was.
- classList.add puts a class on the element. A CSS rule for .full gives it a new colour, and again only the cart is redrawn.
- createElement makes a new <li>, and textContent gives it text. It is not in the tree yet, so the page does not change.
- append puts it as the last child of <ul>. Now it is in the tree, and the list on the page grows by one line.
- JavaScript never draws the page itself. It changes the tree, and the browser draws what changed. The page is not loaded again.
JavaScript draws nothing by itself. It changes the tree, and the browser draws the change. The page is not loaded again, so the text in the fields and the place you scrolled to stay.
Try it
Only 3 left
Show the stock in #stock: "Only 3 left". When there are fewer than 5, add the class low to the element (the CSS makes it red). The bold text from the class stock must stay.
Console
Solution
index.html<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Fresh milk 3%</title>
<link rel="stylesheet" href="style.css">
<script src="script.js" defer></script>
</head>
<body>
<article>
<h1>Fresh milk 3%</h1>
<p id="stock" class="stock">—</p>
</article>
</body>
</html>
style.cssbody { font-family: system-ui, sans-serif; padding: 16px; }
.stock { font-weight: bold; }
.low { color: #c2452a; }
script.jsconst stock = 3;
const text = document.querySelector('#stock');
text.textContent = `Only ${stock} left`;
if (stock < 5) {
text.classList.add('low');
}
text.textContent = `Only ${stock} left` puts in the text, and text.classList.add('low') adds the class next to stock. className = 'low' also makes it red, but it replaces all the classes: stock is gone and the text is no longer bold. And text.class = 'low' does nothing you can see: it is a new field on the object, not the class of the element.
The idea
Attributes, properties and classes
In HTML an element has attributes: value="1", disabled, data-id="7". In JavaScript it has properties: fields of the object. Most attributes have a property with the same name, but the two are not the same thing.
input.valueis what is in the field now. Thevalueattribute is the starting value and does not change while the buyer types. You read the attribute withgetAttribute('value').button.disabled = trueturns the button off. The property istrueorfalse.data-…attributes are for your own data. You read them throughdataset:data-stockisdataset.stock, anddata-product-idisdataset.productId. The value is always a string.- You work with classes through
classList:add,remove,toggle(adds the class if it is missing and removes it if it is there) andcontains.
Console
The console shows that the property is 3, while the attribute is still 1. dataset.stock is "12", a string: turn it into a number with Number(...) before you calculate with it.
Try it
Find the bug
The sale should make every price red, but the console says Cannot read properties of undefined (reading 'add').
Pick the line with the bug.
querySelectorAll returns a NodeList, and a NodeList has no classList: prices.classList is undefined. The class goes on each element: prices.forEach((p) => p.classList.add('sale')).
The idea
New elements
You make a new element in three steps: create it, give it content and put it in the tree.
const li = document.createElement('li'); // a new <li>, not on the page yetli.textContent = 'Milk';li.dataset.id = '2';document.querySelector('ul').append(li); // now it is the last child of <ul>Until you put it in with append, the element is not on the page. append also takes several elements at once: li.append(title, button). prepend puts it at the start, and remove() takes the element out of the tree.
More precisely, if you are curious
More ways to search. getElementById('price') is like querySelector('#price'). getElementsByClassName and getElementsByTagName return a "live" list: it changes when the tree changes. querySelectorAll returns a snapshot. After an append of a new .price, the live list has one more element, and the snapshot does not.
Searching inside an element. Every element has querySelector, not only document: card.querySelector('.price') searches only inside the card.
setAttribute('disabled', 'false') turns the button off. For disabled only the presence of the attribute matters, not its value. So you write button.disabled = false or removeAttribute('disabled').
innerHTML and <script>. A script put in with innerHTML does not run. An attribute like onerror on an image does. That is why innerHTML with someone else's data is dangerous even without <script>.
className versus classList. className is the whole class attribute as one string. Assigning to it replaces all the classes at once.
Exercise
The product card
script.js has a product object. Fill in the card from it: the name is the heading (#name), the price is "€8.90" (two digits after the point), and the stock is "Sold out" when stock is 0. Then the card gets the class sold-out and the button is turned off. Put a data-id with the id of the product on the card. For every label in badges, add an <li> to #badges.
Console
Solution
index.html<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Vitosha cheese</title>
<link rel="stylesheet" href="style.css">
<script src="script.js" defer></script>
</head>
<body>
<article class="product">
<h2 id="name"></h2>
<p id="price"></p>
<p id="stock"></p>
<ul id="badges" aria-label="Labels"></ul>
<button type="button" id="add">Add to cart</button>
</article>
</body>
</html>
style.cssbody { font-family: system-ui, sans-serif; padding: 16px; }
.product { border: 1px solid #ccc; border-radius: 8px; padding: 12px 16px; max-width: 320px; }
.sold-out { opacity: 0.6; }
#badges { list-style: none; padding: 0; display: flex; gap: 8px; }
#badges li { background: #f3efe6; border-radius: 4px; padding: 2px 8px; }
button { font-size: 16px; padding: 8px 16px; }
script.jsconst product = {
id: 7,
name: 'Vitosha cheese',
price: 8.9,
stock: 0,
badges: ['new', 'local'],
};
const card = document.querySelector('.product');
card.dataset.id = product.id;
document.querySelector('#name').textContent = product.name;
document.querySelector('#price').textContent = `€${product.price.toFixed(2)}`;
const stock = document.querySelector('#stock');
const add = document.querySelector('#add');
if (product.stock === 0) {
stock.textContent = 'Sold out';
card.classList.add('sold-out');
add.disabled = true;
} else {
stock.textContent = `In stock: ${product.stock}`;
}
const badges = document.querySelector('#badges');
product.badges.forEach((badge) => {
const li = document.createElement('li');
li.textContent = badge;
badges.append(li);
});
card.dataset.id = product.id writes the attribute data-id="7". The price is `€${product.price.toFixed(2)}`: toFixed(2) gives "8.90". When stock === 0, three things go together: the text, the class and add.disabled = true. The labels are a forEach over the array with createElement('li') and append for each. badges.textContent = product.badges.join(', ') shows the same words, but as one piece of text: the list has no items at all.
Test
Check yourself
Related lessons
Section titled "Related lessons"- Builds on: The HTML document · Forms · Selectors and the cascade
- Continues in: Events · A list from data · Data from an API · The first component
- Tasks on this topic: JavaScript in the page