What is the DOM?
When the browser reads your HTML, it does not keep a flat string of text. It builds a tree of objects called the Document Object Model. Every element becomes a node with properties and methods, every attribute is accessible, and every piece of text has a place. The DOM is that tree, and it is live: change it and the page updates.
This is why JavaScript and HTML feel connected. Your HTML describes the structure, the browser turns it into objects, and JavaScript manipulates those objects. The document object is the entry point to the whole tree.
The node tree
The DOM is hierarchical. The document is the root, <html> is its child, and everything else hangs beneath in a parent-child relationship. A node can have many children but only one parent.
document
└── html
├── head
│ └── title
└── body
├── h1
└── p
Nodes come in several kinds. Element nodes represent tags. Text nodes hold the characters inside elements. Comment nodes represent comments. Most of the time you work with elements, but knowing text nodes exist explains whitespace surprises and why childNodes can include more than you expect.
Selecting elements
Before you can change anything, you have to find it. The modern workhorse is querySelector, which accepts any CSS selector.
// select.js
const title = document.querySelector("h1");
const firstCard = document.querySelector(".card");
const allLinks = document.querySelectorAll("a[target='_blank']");
const byId = document.getElementById("main");
allLinks.forEach((link) => link.classList.add("external"));
querySelector returns the first matching element or null. querySelectorAll returns a static NodeList of every match, which supports forEach but does not update when the DOM changes. The older methods getElementById, getElementsByClassName and getElementsByTagName still work; the last two return live collections that update automatically.
Reading and writing content
Once you have an element, you can read or replace its content.
// content.js
const heading = document.querySelector("#title");
heading.textContent; // read the visible text
heading.textContent = "Hello"; // replace it, escaped as text
heading.innerHTML = "<em>Hi</em>"; // parses HTML
Use textContent whenever the value comes from a user, an API or anywhere you do not fully control. It treats everything as plain text and cannot inject markup. innerHTML parses a string as HTML, which is convenient for trusted templates but a serious cross-site scripting risk with untrusted input. If you must build markup from data, prefer creating elements and setting textContent on each.
Attributes, classes and styles
Attributes live on the element and can be read or changed directly.
// attrs.js
const link = document.querySelector("a");
link.getAttribute("href");
link.setAttribute("href", "/docs");
link.dataset.userId = "42"; // data-user-id="42"
link.classList.add("active");
link.classList.toggle("open");
link.classList.remove("hidden");
link.style.color = "tomato";
classList is the clean way to manage classes — it avoids string concatenation and accidental duplicate classes. The style property sets inline styles one property at a time; for anything thematic, prefer toggling a class and letting CSS decide.
Creating, inserting and removing nodes
You build new nodes with document.createElement and place them with the insertion methods.
// build.js
const item = document.createElement("li");
item.textContent = "New task";
item.classList.add("task");
const list = document.querySelector("#todos");
list.append(item); // add at the end
list.prepend(item); // add at the start
item.remove(); // detach from the tree
append accepts multiple nodes and even strings, while insertBefore gives you precise placement. When you need to insert many nodes, build them off-document and attach once:
// fragment.js
const fragment = document.createDocumentFragment();
for (const name of names) {
const li = document.createElement("li");
li.textContent = name;
fragment.append(li);
}
list.append(fragment);
A DocumentFragment is a lightweight container that is not part of the page. Attaching it moves all of its children in a single operation, which triggers far less layout work than adding them one at a time.
Listening to events
Events are how your code responds to the user and the browser. Attach a listener with addEventListener.
// events.js
const button = document.querySelector("#save");
button.addEventListener("click", (event) => {
event.preventDefault();
console.log("clicked", event.target);
});
The event object carries useful information: target is the element that triggered the event, currentTarget is the element the listener is attached to, and preventDefault() cancels the browser’s default behaviour. Common events include click, input, change, submit, keydown, scroll and DOMContentLoaded.
Event delegation
Instead of attaching a listener to every child, attach one to a parent and inspect event.target. Because events bubble from the target up through its ancestors, the parent sees them all.
// delegate.js
const list = document.querySelector("#todos");
list.addEventListener("click", (event) => {
const button = event.target.closest("button.delete");
if (!button) return;
button.closest("li").remove();
});
This is more efficient and, crucially, keeps working for elements added after the listener was attached. For dynamic lists and tables, delegation is the default pattern.
Traversing the tree
Sometimes you need to move from one node to its relatives.
// traverse.js
const item = document.querySelector(".item");
item.parentElement; // nearest ancestor element
item.children; // child elements only
item.firstElementChild; // first child element
item.nextElementSibling; // next sibling element
item.closest(".card"); // nearest ancestor matching a selector
The Element variants skip text and comment nodes, which is almost always what you want. closest is especially handy in event handlers, as the delegation example shows.
Performance and best practices
- Batch your reads and writes. Reading layout properties like
offsetHeightforces the browser to recalculate; interleaving reads and writes causes repeated reflows. - Build off-document. Use a
DocumentFragmentor an off-screen element, then attach once. - Cache your lookups. Store elements in variables instead of querying in a loop.
- Prefer classes over inline styles. Toggle a class and keep presentation in CSS.
- Use
textContentby default. Only useinnerHTMLwith trusted, sanitised content. - Delegate events for lists and anything dynamic.
- Remove listeners you no longer need, especially on long-lived pages, to avoid leaks.
Common mistakes
- Querying an element before it exists, then wondering why it is
null. - Using
innerHTMLwith user input and opening an XSS hole. - Attaching a listener to every row instead of delegating to the table.
- Reading and writing layout properties in the same loop.
- Mixing up
event.targetandevent.currentTarget. - Assuming
querySelectorAllis live when it is a static snapshot.
Where to go next
The DOM is the meeting point of everything you have learned. Combine it with ES6+ syntax for cleaner code, then feed it data with the Fetch API and keep it resilient with error handling. The fastest way to internalise it is to rebuild something small — a to-do list, a filterable gallery or a live search — entirely in vanilla JavaScript.