What the DOM Is
When the browser loads your HTML, it doesn't just draw it — it builds a live model of the page in memory called the DOM (Document Object Model). JavaScript talks to this model, and the screen updates instantly.
The DOM is a tree. At the top is document. Under it, <html>, then <head> and <body>, and every tag you wrote becomes a node hanging off its parent. Changing a node changes what the visitor sees.
HTML becomes a tree of nodes
- At the root sits
document— your entry point to the whole page from JavaScript. - The
<html>element is its only child. - It branches into
<head>and<body>. - Inside the body, each tag —
<h1>,<p>— is a node. - A
<button>node too. JavaScript can grab any of these and change it.
The DOM is not your HTML fileYour .html file is the starting blueprint. The DOM is the living version in memory. When JavaScript edits the DOM, the file on disk never changes — only what's on screen does.
Selecting Elements
Before you can change something, you have to grab it. That means selecting a node from the DOM and storing it in a variable.
The one method to remember is document.querySelector(). You pass it a CSS selector — the exact same syntax you learned in the CSS course — and it returns the first matching element:
const title = document.querySelector("#title"); // by id
const btn = document.querySelector(".btn"); // by class
const all = document.querySelectorAll("li"); // ALL matchesNodeLists vs Arrays
When you use querySelectorAll, it returns a NodeList, not a regular JavaScript Array. While you can use .forEach() on a NodeList, it lacks useful array methods like .map(), .filter(), or .reduce(). You can easily convert it to a real array:
const nodes = document.querySelectorAll("li");
// Convert using Array.from() or the spread operator [...]
const nodeArray = [...nodes];
nodeArray.filter(el => el.textContent.includes("Done"));querySelector reaches into the tree
querySelector("#title")finds the element whoseidistitleand returns it.querySelector(".btn")finds the first element with classbtn. Same CSS selectors you already know.
Store selections in variablesSelecting the DOM has a small cost, so grab an element once, save it in a const, and reuse that variable — don't call querySelector over and over for the same element.
Traversing the DOM
Sometimes you don't know the exact class or ID of an element, but you know where it is relative to another element. The DOM allows you to "walk" the tree — moving up, down, and sideways from any node.
element.parentElement— Move up one level (e.g., from<li>to<ul>).element.children— Get a list of all elements directly inside this one.element.nextElementSibling— Move to the next sibling element at the same level.element.closest("selector")— Move up the tree until it finds the nearest ancestor matching the selector (very useful for events).
const item = document.querySelector(".active");
const list = item.parentElement; // The <ul> holding this item
const next = item.nextElementSibling; // The next <li>
const card = item.closest(".card"); // The closest parent with class .cardWhy traverse?It's crucial when building dynamic interfaces where multiple identical elements exist (like a list of products). If a user clicks the "Delete" button inside product #42, you can use button.closest(".product-card") to find and delete exactly that card without needing to give every card a unique ID.
Changing Content & Styles
Once you hold an element, you can rewrite its text, swap its HTML, and restyle it — all live, with no page reload.
element.textContent = "..."— change the text inside.element.innerHTML = "..."— replace with new HTML.element.style.color = "red"— set a CSS property directly.element.classList.add("active")— add/remove/toggle a CSS class (the professional way).
Attributes & Custom Data
You can also read or change any HTML attribute (like src, href, disabled), and store custom data directly in the HTML using data-* attributes.
img.setAttribute("src", "new-image.jpg"); // Change an attribute
const link = btn.getAttribute("href"); // Read an attribute
// Given HTML: <button data-id="42" data-user-role="admin">
const id = btn.dataset.id; // "42"
const role = btn.dataset.userRole; // "admin" (camelCase)Rewriting an element on the fly
- Grab the element and store it as
box. box.textContent = "Updated!"instantly changes the words on screen.box.style.background = "#b534ff"restyles it directly.box.classList.add("active")applies a whole CSS class at once — the cleanest approach.
Events & Listeners
An event is something that happens: a click, a keypress, a mouse move, a form submit. You tell the browser "when this event happens on this element, run this function." That function is the event handler.
const btn = document.querySelector(".btn");
btn.addEventListener("click", () => {
alert("You clicked me!");
});Read it plainly: "on the button, add an event listener for click; when it fires, run this arrow function." The listener waits patiently forever, reacting every single time.
A click fires a handler
- The listener is attached and waiting. Nothing happens yet.
- The user moves to the button and clicks.
- The
clickevent fires and the handler function runs. - Click again and it runs again — a listener reacts every time, for as long as the page is open.
Common eventsclick, input (typing in a field), submit (a form), keydown (a key), mouseenter/mouseleave (hover). Same pattern for all: element.addEventListener("event", handler).
Forms & The Event Object
When an event fires, the browser automatically passes an Event Object (usually named e or event) into your handler. This object contains all the details about what just happened: which key was pressed, where the mouse was, or what element triggered the event.
Stopping Default Behavior
The most common use of the event object is handling forms. By default, submitting a form refreshes the whole page. You almost never want this in a modern web app. We use e.preventDefault() to stop the browser's default action so our JavaScript can handle the data instead.
const form = document.querySelector("form");
const input = document.querySelector("#username");
form.addEventListener("submit", (e) => {
e.preventDefault(); // Stop page reload!
const userName = input.value; // Read what they typed
console.log("Welcome, " + userName);
input.value = ""; // Clear the input field
});Reading Input ValuesNotice that to read what a user typed in a text input (or text area), you don't use textContent. You use element.value. For checkboxes, you check element.checked (which returns true or false).
Putting It Together: a Live Counter
Everything you've learned in three courses meets here. HTML for structure, CSS for looks, JavaScript + the DOM + an event to make it do something. This is a real, working app.
let count = 0;
const display = document.querySelector("#count");
const btn = document.querySelector("#plus");
btn.addEventListener("click", () => {
count++; // change the data
display.textContent = count; // update the DOM
});Data changes, the DOM follows
- Click. The handler runs
count++→ the variable is now 1, andtextContentupdates the display. - Click again →
countis 2. The number on screen tracks the data. - Every click repeats the cycle: change the data → update the DOM. That loop is the essence of every web app.
You just built the core of every appA to-do list, a shopping cart, a like button, a game score — all of them are "hold some data, react to an event, update the DOM." Open the Editor and build your own counter right now.
Creating & Removing Elements — Build a To-Do List
The counter changed an element that already existed. Real apps go further: they create brand-new elements from data and remove them on demand. Master this and you can build a to-do list, a chat, a shopping cart — anything.
Three DOM methods do it: document.createElement() makes a new element, element.append() puts it into the page, and element.remove() deletes it:
addBtn.addEventListener("click", () => {
const li = document.createElement("li"); // make a new element
li.textContent = input.value; // fill it from the input
list.append(li); // add it to the page
li.addEventListener("click", () => li.remove()); // click to delete
});Using HTML Templates
Building complex structures with createElement over and over gets messy. The <template> tag solves this. It holds hidden HTML that the browser doesn't render, but JavaScript can clone and use it to stamp out new UI instantly.
<template id="card-template">
<div class="card">
<h3 class="title"></h3>
<button class="delete-btn">Delete</button>
</div>
</template>const template = document.querySelector("#card-template");
// Clone the entire hidden structure
const clone = template.content.cloneNode(true);
// Fill it with dynamic data
clone.querySelector(".title").textContent = "New Task";
document.body.append(clone);Adding and removing to-do items
- Type "Buy milk" into the input.
- Click Add →
createElement("li")+append()creates a brand-new item on the page. - Type "Walk dog" and add it too — the list grows from data you supplied.
- Click the × on "Buy milk" →
li.remove()deletes just that element. You've built a working to-do app.
This is a real, portfolio-worthy projectA to-do list touches everything: input, events, creating elements, removing them, and (with localStorage) even saving data between visits. It's the classic "I can actually build things now" milestone. Try it in the Editor!
The Fetch API: Talking to Servers
So far your pages only work with data you typed in yourself. Real apps get their data from servers over the internet — a weather app, a feed, a product list. The tool that does this in the browser is fetch, and it uses the async skills from the JavaScript course.
What an API request looks like
An API is a URL you can ask for data. You send a request with fetch(), wait for the response (it takes time, so it's asynchronous), and turn the reply — usually JSON text — into a real JavaScript object with .json():
async function loadUser() {
const res = await fetch("https://api.site.com/user/1");
const user = await res.json(); // JSON text → JS object
document.querySelector("#name").textContent = user.name;
}Fetching data and rendering it into the page
- You call
fetch("…/user/1"). A GET request leaves your page and travels to the API server. The card shows a loading spinner. - The server finds the user and replies with JSON text — a string of key/value data.
await res.json()parses that text into a real JavaScript object you can use.- You write the object's fields into the DOM with
textContent— and the card fills with the real data. Fetch + the DOM = a live, data-driven app.
Always handle the failure caseRequests fail — no signal, server down, bad URL. Wrap fetch in try/catch, check res.ok, and show the user a friendly "couldn't load, retry?" message instead of a blank screen. Robust apps assume the network will let them down.
Saving Data with localStorage
Here's a problem you'll hit fast: any variable your app holds vanishes the moment the user reloads or closes the tab. To make data stick — a saved to-do list, a theme choice, a high score — the browser gives you a tiny built-in database called localStorage.
Two methods do almost everything
localStorage stores simple key/value strings directly on the user's device, and they survive reloads, tab closes, even restarts:
// SAVE a value under a key
localStorage.setItem("score", 3);
// READ it back later (even after a reload)
const saved = localStorage.getItem("score"); // "3"A variable is forgotten on reload — localStorage isn't
- The app runs. A normal variable holds
score = 3, and we also callsetItem("score", 3)— writing it to localStorage on the device. - The user reloads the page. The normal variable is wiped back to
0— everything in memory is forgotten. - But localStorage lives on disk, not in memory. The reload didn't touch it — it still holds
"3". - On load,
getItem("score")reads it back and your app restores the score to 3. The data survived. That's persistence.
Two things to rememberlocalStorage only stores strings — to save an object or array, wrap it with JSON.stringify(...) on the way in and JSON.parse(...) on the way out. And never store secrets (passwords, tokens) there: any script on the page can read it. It's for convenience data, not sensitive data.
Event Delegation & Bubbling
Here's a real problem: you have a to-do list with 200 items, each needing a click handler. Do you attach 200 listeners? What about items added later that don't exist yet? There's an elegant answer, and it relies on understanding how events actually travel.
Events bubble up
When you click a button inside a list item inside a list, the click doesn't just fire on the button. It bubbles — the event travels up through every ancestor: button → list item → list → body. Each ancestor gets a chance to react. This bubbling is the key insight most beginners never learn.
A click bubbling up the DOM tree
- The user moves to click the delete
✕button inside a list item. - The click fires on the
<button>— the deepest element (the "target"). - Then it bubbles up to the parent
<li>… - …and up to the
<ul>. So we put one listener on the<ul>and catch every child's click there. That's event delegation.
Delegation: one listener to rule them all
Instead of a listener per item, you attach a single listener to the parent and check which child was actually clicked using event.target:
list.addEventListener("click", (e) => {
if (e.target.matches(".delete-btn")) {
e.target.closest("li").remove(); // delete that item
}
});Two big winsDelegation means less memory (one listener, not thousands) and — crucially — it works for elements added later. New list items you create dynamically are instantly handled, because the listener lives on the parent that was always there. (And event.stopPropagation() lets you halt bubbling when you need to.)
Advanced: DOM Performance & Fragments
Every time you touch the DOM, the browser has to recalculate the layout and repaint the screen (called Reflow and Repaint). Doing this repeatedly inside a loop will make your app sluggish.
The DocumentFragment Solution
Instead of appending elements one by one directly to the live DOM, you can use a DocumentFragment. It's an invisible mini-DOM that exists only in memory. You append everything to it, and then append the fragment to the live DOM once.
const list = document.querySelector("#huge-list");
const fragment = document.createDocumentFragment(); // 🧠 In-memory container
for (let i = 0; i < 1000; i++) {
const li = document.createElement("li");
li.textContent = `Item ${i}`;
fragment.append(li); // ⚡ Fast: No reflow!
}
list.append(fragment); // 🎨 Only ONE reflow and repaint!Layout ThrashingAvoid interleaving reads and writes to the DOM. If you change a style (write) then measure it with element.offsetHeight (read), you force the browser to synchronously recalculate layout on the spot. Doing this in a loop causes heavy "layout thrashing". Always read first, then write.
Advanced: Intersection Observer API
How do you know when an element scrolls into view? The old way was listening to the scroll event, which is terrible for performance. The modern, highly optimized way is the Intersection Observer.
It acts like a camera watching elements. When an element intersects with the viewport (enters the screen), it triggers a callback. Perfect for lazy loading images, infinite scrolling, or trigger-on-scroll animations.
const cards = document.querySelectorAll(".fade-in-card");
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
entry.target.classList.add("visible"); // Animate it!
observer.unobserve(entry.target); // Stop watching
}
});
});
cards.forEach(card => observer.observe(card)); // Start watchingAdvanced: Custom Events
Built-in events (click, input, submit) are great, but as your app grows, different parts of your code need to talk to each other without being tightly coupled. You can create your own Custom Events and broadcast them across the DOM.
Imagine a Shopping Cart that updates whenever a "Product Added" event is fired from anywhere on the page, passing the product data along in the detail property.
// 1. Listen for our custom event anywhere in the app
document.addEventListener("cart:add", (e) => {
const product = e.detail; // The custom payload
console.log(`Added ${product.name} ($${product.price})`);
});
// 2. Sometime later, dispatch the event from a button click
const buyBtn = document.querySelector("#buy-shoes");
buyBtn.addEventListener("click", () => {
const event = new CustomEvent("cart:add", {
detail: { name: "Nike Air", price: 120 } // Attach data here
});
document.dispatchEvent(event); // 📢 Broadcast it!
});Advanced: MutationObserver API
Sometimes you need to know when the DOM itself changes. Maybe a third-party script adds an ad to your page, or a dynamically loaded component finally renders. You can't just attach an event listener for DOM changes (the old ones were too slow). Enter the MutationObserver.
It efficiently watches a DOM element (like document.body or a specific container) and triggers a callback whenever nodes are added/removed, attributes change, or text is modified inside it.
const container = document.querySelector("#feed");
const observer = new MutationObserver((mutations) => {
mutations.forEach(mutation => {
if (mutation.type === "childList") {
console.log("New elements were added or removed!");
}
});
});
// Start watching for changes to child elements
observer.observe(container, { childList: true });Your pages are alive now ⚡
You can select elements, change the page, and respond to the user. That's real front-end development. The final course introduces React — the professional framework that scales this up to full applications.