JavaScript · Intermediate
DOM Basics in JavaScript
In short: The DOM (Document Object Model) is the browser's live tree of objects representing a web page. JavaScript running in that page reaches it through document: querySelector finds elements, textContent and classList change them, and createElement with append adds new ones. The DOM exists only inside a browser page, not in Node.js or a Web Worker.
The page as a tree of objects
When a browser loads HTML it builds a tree: the document, containing an html element, containing head and body, and so on down to each paragraph, list item and piece of text. That tree is the DOM, and every node in it is a JavaScript object with properties and methods. Change the object and the page changes on screen; that is how every interactive page works, from a counter that updates to a whole application that redraws itself.
Work starts by selecting an element. document.getElementById("title") returns the one element with that id or null. document.querySelector(".note") takes any CSS selector and returns the first match; querySelectorAll returns every match as a NodeList, which you can loop with for...of or forEach. Selection is where beginners lose the most time: a selector that matches nothing returns null, and the next line, null.textContent = ..., throws a TypeError. Check the selector and the spelling of the id or class first.
Once you hold an element you can read and change it. textContent is the text inside it, with markup treated as plain characters; assigning to it replaces the contents safely. innerHTML is the markup inside it: reading gives HTML, assigning parses the string into new elements. Use textContent for anything that came from a user or a server, because a string assigned to innerHTML can contain tags with event-handler attributes that run code. Attributes are read and written with getAttribute and setAttribute, common ones such as href, value and src are also plain properties, and element.style.color = "red" sets an inline style. classList.add, remove, toggle and contains manage classes, which is usually better than touching styles directly, since the look stays in the stylesheet.
New content is created with document.createElement("li"), filled with textContent or child elements, and attached with parent.append(child) (or the older appendChild). Until it is appended the element exists only in memory. element.remove() takes it back out, and parent.children or element.parentElement navigate the tree. Building elements one at a time and appending them is both safer than assembling an HTML string and easier to debug.
Timing matters. A <script> placed in <head> runs before the <body> has been parsed, so querySelector finds nothing. Either give the script the defer attribute (module scripts defer automatically), place it at the end of <body>, or wait for the DOMContentLoaded event, which the events lesson covers.
The DOM is provided by the browser, not by the JavaScript language. Node.js has no document, and neither does a Web Worker, which is where this site runs your programs; typeof document prints "undefined" in both. The runnable examples below therefore build a small stand-in tree with plain objects to show the same operations, and the syntax block shows the real calls to use in a page.
Syntax (real DOM calls, shown but not run here)
// select
const title = document.getElementById("title");
const firstNote = document.querySelector(".note");
const notes = document.querySelectorAll("p.note"); // NodeList
// read and change
title.textContent = "Menu for week 12"; // safe for any text
firstNote.innerHTML = "<em>Draft</em>"; // parsed as HTML: trusted markup only
firstNote.classList.remove("hidden");
firstNote.setAttribute("data-status", "final");
firstNote.style.color = "darkgreen";
// create, attach, remove
const item = document.createElement("li");
item.textContent = "buy stamps";
document.querySelector("ul").append(item);
item.remove();
// run after the page is parsed
<script src="app.js" defer></script>querySelector returns null when nothing matches. querySelectorAll returns a static NodeList; convert with [...list] when you need array methods such as map.
No document here, and a stand-in for creating elements
The first line shows that this environment has no DOM. The rest models createElement, append and classList with plain objects and renders the tree as HTML so you can see the result.
console.log("document here:", typeof document);
function createElement(tag, text) {
return { tag, text: text || "", children: [], classes: [] };
}
function append(parent, child) {
parent.children.push(child);
return child;
}
function render(el, depth = 0) {
const pad = " ".repeat(depth);
const cls = el.classes.length ? ` class="${el.classes.join(" ")}"` : "";
if (el.children.length === 0) return `${pad}<${el.tag}${cls}>${el.text}</${el.tag}>`;
const inner = el.children.map(c => render(c, depth + 1)).join("\n");
return `${pad}<${el.tag}${cls}>\n${inner}\n${pad}</${el.tag}>`;
}
const list = createElement("ul");
let line;
while ((line = readline()) !== null) {
const item = append(list, createElement("li", line));
if (line.startsWith("!")) item.classes.push("urgent");
}
console.log(render(list));
console.log(list.children.length + " items");Input given to the program: water the ferns ↵ !renew library card ↵ buy stamps
Output
document here: undefined <ul> <li>water the ferns</li> <li class="urgent">!renew library card</li> <li>buy stamps</li> </ul> 3 items
In a page the loop would read const item = document.createElement("li"); item.textContent = line; list.append(item); and item.classList.add("urgent"), and the browser would draw the list instead of printing markup. The shape is the same: create a node, fill it, attach it to a parent, adjust its classes. list.children.length corresponds to the real children collection of an element.
Finding elements and changing what they show
A small tree stands in for a page. getElementById and getElementsByClassName are written as tree walks; the code that uses them is what you would write against the real DOM.
const page = {
tag: "main", id: "", classes: [], text: "", children: [
{ tag: "h1", id: "title", classes: [], text: "Weekly menu", children: [] },
{ tag: "p", id: "", classes: ["note"], text: "Draft", children: [] },
{ tag: "p", id: "", classes: ["note", "hidden"], text: "Prices exclude tax", children: [] }
]
};
function walk(el, visit) {
visit(el);
el.children.forEach(child => walk(child, visit));
}
function getElementById(root, id) {
let found = null;
walk(root, el => { if (found === null && el.id === id) found = el; });
return found;
}
function getElementsByClassName(root, className) {
const out = [];
walk(root, el => { if (el.classes.includes(className)) out.push(el); });
return out;
}
const title = getElementById(page, "title");
title.text = "Menu for week 12";
console.log(title.tag, "->", title.text);
for (const note of getElementsByClassName(page, "note")) {
const idx = note.classes.indexOf("hidden");
if (idx !== -1) note.classes.splice(idx, 1);
console.log(note.text, note.classes);
}
console.log(getElementById(page, "footer"));Output
h1 -> Menu for week 12 Draft [ 'note' ] Prices exclude tax [ 'note' ] null
Setting title.text plays the role of title.textContent = ...; removing "hidden" from the classes array is note.classList.remove("hidden"), which in a page would make the paragraph visible if the stylesheet hides that class. The last line matters most: asking for an id that does not exist returns null, and any property access on that result would throw. Real code guards with if (el) { ... } or fixes the selector.
textContent versus innerHTML with untrusted text
A visitor's comment contains tags. The program shows what each property would do with it and how to escape text that must be embedded in markup.
function escapeHtml(text) {
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
}
const comment = readline();
const tags = comment.match(/<[a-z]+/g) || [];
console.log("textContent would show exactly:", comment);
console.log("innerHTML would create " + tags.length + " element(s): " + tags.map(t => t.slice(1)).join(", "));
console.log("escaped for markup:", escapeHtml(comment));Input given to the program: Great <b>deal</b>! <img src=x onerror=sendDataAway()>
Output
textContent would show exactly: Great <b>deal</b>! <img src=x onerror=sendDataAway()> innerHTML would create 2 element(s): b, img escaped for markup: Great <b>deal</b>! <img src=x onerror=sendDataAway()>
Assigned to textContent, the comment is displayed character for character, angle brackets included, and nothing runs. Assigned to innerHTML, the browser parses it: the b element makes the word bold, and the img element's onerror attribute executes whatever it contains as soon as the image fails to load. That is a script injection. The rule is simple: text from users, URLs or APIs goes into textContent, or is escaped as the last line shows before it is placed in markup.
Ways to select elements
| Call | Returns | Notes |
|---|---|---|
| document.getElementById("id") | one element or null | fastest for a unique id |
| document.querySelector("css") | first match or null | any CSS selector: "#id", ".class", "ul > li" |
| document.querySelectorAll("css") | static NodeList | does not update when the page changes; supports forEach |
| document.getElementsByClassName("c") | live HTMLCollection | updates as elements come and go; no forEach |
| element.querySelector("css") | first match inside that element | scope a search to part of the page |
| element.closest("css") | nearest ancestor (or itself) matching | useful in event handlers |
Common mistakes
Running the script before the elements exist
Why it goes wrong: A script in
<head>withoutdeferruns while the body is still unparsed, so everyquerySelectorreturnsnulland the first property access throws a TypeError.Fix: Add
deferto the script tag, usetype="module", put the script at the end of the body, or listen forDOMContentLoaded.JavaScript · fix<script src="app.js" defer></script>Putting user-supplied text into innerHTML
Why it goes wrong: The browser parses the string as HTML. Tags and event attributes inside it become live elements and code, which is how cross-site scripting attacks start.
Fix: Assign to
textContent, or create elements withcreateElementand set their text. ReserveinnerHTMLfor markup you wrote yourself.Forgetting the selector prefix
Why it goes wrong:
querySelector("title")looks for a<title>element, not the element with idtitle.getElementById("#title")looks for an id that literally starts with#. Both returnnullor the wrong node.Fix: Use
#for ids and.for classes inquerySelector; pass the bare id togetElementById.Calling array methods on a NodeList or HTMLCollection
Why it goes wrong:
querySelectorAllreturns a NodeList, which hasforEachbut notmaporfilter;getElementsByClassNamereturns an HTMLCollection with neither.Fix: Spread into an array first:
[...document.querySelectorAll(".row")].map(...).
Where you use this
Every interactive page is DOM manipulation at some level: showing a validation message next to a form field, adding a row to a table when the user clicks "add", filling a template with data fetched from a server, toggling a dark class on the body. Frameworks such as React and Vue generate these calls for you, but debugging them, writing a small widget without a framework, or working inside a browser extension all come back to the operations in this lesson.
The usual shape of such code is: select the container once, build new elements from data with createElement and textContent, and append them in a loop. Rendering a list of search results, a chat transcript or a set of product cards is that pattern repeated, and keeping the data in ordinary arrays and objects while the DOM only reflects it makes the code easy to reason about.
const list = document.querySelector("#results");
list.replaceChildren();
for (const hit of results) {
const li = document.createElement("li");
li.textContent = hit.title;
list.append(li);
}Key points
- The DOM is the browser's object tree for the page;
documentis its root, and it exists only in a browser page. getElementByIdandquerySelectorreturn one element ornull;querySelectorAllreturns a NodeList.- Use
textContentfor text and reserveinnerHTMLfor markup you trust. - Change appearance through
classList, keeping the styles in CSS. - Create with
createElement, fill it, then attach withappend;remove()detaches. - Run DOM code after the page is parsed:
defer, a module script, orDOMContentLoaded.
Try it yourself
Complete the loop so that every input line becomes an li element under the list. A line that starts with x followed by a space is a finished task: strip that marker from the text and give the element the class done.
function createElement(tag, text) {
return { tag, text: text || "", children: [], classes: [] };
}
function render(el, depth = 0) {
const pad = " ".repeat(depth);
const cls = el.classes.length ? ` class="${el.classes.join(" ")}"` : "";
if (el.children.length === 0) return `${pad}<${el.tag}${cls}>${el.text}</${el.tag}>`;
const inner = el.children.map(c => render(c, depth + 1)).join("\n");
return `${pad}<${el.tag}${cls}>\n${inner}\n${pad}</${el.tag}>`;
}
const list = createElement("ul");
let line;
while ((line = readline()) !== null) {
// create an li for the line, mark finished tasks, add it to list.children
}
console.log(render(list));call the vet ↵ x post the parcel<ul>
<li>call the vet</li>
<li class="done">post the parcel</li>
</ul>function createElement(tag, text) {
return { tag, text: text || "", children: [], classes: [] };
}
function render(el, depth = 0) {
const pad = " ".repeat(depth);
const cls = el.classes.length ? ` class="${el.classes.join(" ")}"` : "";
if (el.children.length === 0) return `${pad}<${el.tag}${cls}>${el.text}</${el.tag}>`;
const inner = el.children.map(c => render(c, depth + 1)).join("\n");
return `${pad}<${el.tag}${cls}>\n${inner}\n${pad}</${el.tag}>`;
}
const list = createElement("ul");
let line;
while ((line = readline()) !== null) {
const done = line.startsWith("x ");
const item = createElement("li", done ? line.slice(2) : line);
if (done) item.classes.push("done");
list.children.push(item);
}
console.log(render(list));Practise this
Exercises for this lesson are in the JavaScript practice set.
Open the JavaScript playground
Related lessons
- Events in JavaScriptHow JavaScript events work: addEventListener and removeEventListener, the event object, preventDefault, custom events, bubbling and event delegation.11 min
- JavaScript Objects: Properties, Methods and NestingHow JavaScript objects group data as key-value properties: dot and bracket access, adding and deleting, methods with this, nesting and reference behaviour.10 min
- JavaScript Arrays: Creating, Indexing and Changing ListsHow JavaScript arrays store ordered lists: indexing, length, push and pop, slice versus splice, sorting numbers correctly, and map, filter and reduce.10 min
- JavaScript Strings: Methods, Template Literals and SlicingJavaScript string basics: quotes, template literals, length and indexing, slice, split and join, trim, replace and case methods, and why strings never change.10 min
Frequently asked questions
What is the difference between textContent and innerHTML?
textContent treats the value as plain text: reading returns the text of the element and its descendants with tags stripped, and assigning displays the string exactly, angle brackets included. innerHTML treats the value as markup: reading returns HTML, and assigning parses the string into elements, which can execute code if the string contains event attributes. Use textContent unless you deliberately want to insert markup you wrote.
Why does querySelector return null?
Because nothing in the document matched the selector at the moment it ran. The three usual causes are a typo in the id or class, a missing # or . prefix, and the script running before the element was parsed. Log document.readyState and the selector, and make sure the script is deferred or placed after the element.
Can I use the DOM in Node.js?
Not directly: Node.js has no document or window, because there is no page. Libraries such as jsdom implement the DOM in JavaScript for tests and server-side tooling, and can render HTML strings to a tree you can query. For real pages you run the code in a browser.
How this page was checked. Every program on it was run with Node.js 22 at build time; runs in your browser in an isolated Web Worker by the publishing checks, and the output shown is what it printed. The Run buttons execute the same code on your device; nothing you type is sent anywhere.