DOM and Events
The Document Object Model (DOM) is the bridge between JavaScript and the web page. It turns HTML into a tree of objects that JavaScript can read and modify. Combined with events, the DOM lets you build interactive web pages that respond to clicks, typing, scrolling, and more. In this lesson you will learn how to select elements, change content, create new elements, and respond to user actions — with real DOM code, not a printed model of one.
What You'll Learn
- How the DOM represents an HTML page as a tree of nodes
- How to select elements with
querySelectorandquerySelectorAll - How to modify content, attributes, and styles
- How to create and remove elements dynamically
- How to handle events with
addEventListener - Event delegation for efficient event handling
How this lesson runs
This lesson has code in two kinds of places, and the difference is not cosmetic:
- Pure-logic snippets run right here. Where the code is only about JavaScript values — no page involved — it lives in a runnable
playgroundblock, and the// outputcomments are its real console output. Use them to check your understanding without leaving the page. - DOM code runs in your browser. The
document, elements, and events do not exist in the in-page runner. So every snippet that touches the DOM is plain reference code, and the lesson builds toward a singleindex.htmlfile you save and open yourself. When you reach "Try It Yourself," that file is where the real DOM code lives — the same shape as the capstone's Task Manager renderer you are working toward.
What Is the DOM?
When a browser loads an HTML page, it parses the markup and builds a tree structure in memory. Each HTML element becomes a node in this tree. JavaScript reads and modifies that tree, and the browser immediately reflects the changes on screen.
Take this small page:
<body>
<h1>Hello World</h1>
<div id="content">
<p>First paragraph</p>
<p>Second paragraph</p>
</div>
</body>
The browser turns it into a tree of nodes: body has two children (h1 and div), and the div has two p children. Every node is an object with properties (textContent, id, className) and methods (appendChild, addEventListener). "Manipulating the DOM" just means reading and setting those properties and calling those methods — which is what the rest of this lesson does.
The tree relationships have names you will see everywhere: a node's parentElement, its children, its nextElementSibling. document is the root you start from — document.body is that <body> node, and document is where the selection methods below live.
Selecting Elements
To do anything to an element, you first need a reference to it. In the browser you get one with document.querySelector (the first match) and document.querySelectorAll (all matches). Both take a CSS selector string, which is what makes them powerful — the same selectors you write in CSS work here.
This is real browser code — save it into an HTML file's script element and it runs against the page above:
// First match only — returns the element, or null if nothing matches.
const heading = document.querySelector("h1");
const content = document.querySelector("#content"); // by id
const firstPara = document.querySelector("#content p"); // descendant
// All matches — returns a (static) NodeList you can loop over.
const paragraphs = document.querySelectorAll("#content p");
paragraphs.forEach((p) => {
console.log(p.textContent);
});
// querySelector returns null when nothing matches — always a possibility.
const missing = document.querySelector(".does-not-exist"); // null
The selectors you will reach for constantly:
document.querySelector("#id"); // by id
document.querySelector(".class"); // by class
document.querySelector("button"); // by tag name
document.querySelector("div > p"); // direct child
document.querySelector("div p"); // any descendant
document.querySelector("[data-id]"); // by attribute
Two traps worth knowing now: querySelector returns null when nothing matches (reaching for .textContent on null throws), and querySelectorAll returns a static list — a snapshot. Elements you add to the page after the call do not appear in a list you already grabbed. That single fact is the reason event delegation exists, later in this lesson.
Predict
A page has one <h1> and no element with class 'subtitle'. What does this code log?
const heading = document.querySelector("h1");
const subtitle = document.querySelector(".subtitle");
console.log(heading.textContent);
console.log(subtitle.textContent);Modifying Elements
Once you hold a reference, you change the element by setting its properties. This is real DOM code — each assignment updates the live page instantly:
const heading = document.querySelector("h1");
// Text content — the safe way to set text. Treats the value as plain text.
heading.textContent = "New Title";
// innerHTML parses the string as HTML. Powerful, and dangerous with user input
// (see the warning below) — here the content is a trusted literal.
heading.innerHTML = "<em>Italic Title</em>";
// Inline styles are set through the .style object (camelCase property names).
heading.style.color = "blue";
heading.style.fontSize = "24px";
// classList is the right way to work with classes — never string-concatenate className.
heading.classList.add("highlight");
heading.classList.remove("title");
heading.classList.toggle("active"); // add if absent, remove if present
console.log(heading.classList.contains("highlight")); // true
// Attributes: setAttribute / getAttribute, and the dataset for data-* attributes.
heading.setAttribute("role", "heading");
heading.dataset.section = "intro"; // sets data-section="intro"
Warning —
textContentvsinnerHTML:textContenttreats its value as literal text;innerHTMLparses it as markup. SettinginnerHTMLfrom anything a user typed is a cross-site scripting (XSS) hole — a value containing animgtag with an error-handler attribute would execute its script. Rule of thumb: user input goes in withtextContent, always. You will see this same rule enforced in the capstone renderer.
Creating and Removing Elements
You are not limited to elements already in the HTML. document.createElement builds a new element in memory; appendChild (or append) inserts it into the tree, where the browser renders it. This is the heart of dynamic UI — and exactly what the capstone's renderer does to turn a list of tasks into a list of <li>s.
Real browser code, building a list from an array:
const items = ["Learn HTML", "Learn CSS", "Learn JavaScript"];
const ul = document.createElement("ul");
for (const text of items) {
const li = document.createElement("li");
li.textContent = text; // user-safe: sets text, not markup
ul.appendChild(li); // attach the li to the ul (still in memory so far)
}
document.body.appendChild(ul); // now it is on the page
// Removing is just as direct:
const firstItem = ul.querySelector("li");
firstItem.remove(); // removes it from the tree (modern, preferred)
// ul.removeChild(firstItem); // the older equivalent
Building the whole <ul> in memory and appending it once — rather than appending each <li> to the live page one at a time inside the loop — is the habit worth forming, but for a plainer reason than you may have been told: the loop that builds one thing and the code that inserts it stay separate and readable. It is not measurably faster. Building 2000 <li>s costs Chrome about half a millisecond of scripting either way, and once the layout and paint that follow are counted too, both approaches land around 30 ms — the same, within noise. The browser postpones layout until something actually needs the result, so it makes no difference whether you handed it the elements one at a time or all at once.
Transfer
Event delegation is one instance of a more general idea: instead of wiring up N handlers, you install ONE handler on a shared point and let it dispatch based on what it received. Which of these is the SAME idea applied outside the DOM?
Event Handling
Events are things that happen on the page: clicks, key presses, form submissions, mouse movements. You respond to them with addEventListener(type, handler). The browser calls your handler and passes it an event object describing what happened:
const button = document.querySelector("#save-button");
button.addEventListener("click", (event) => {
console.log("Clicked!", event.type); // "Clicked! click"
console.log(event.target); // the element that was clicked
});
// A form's submit event is the one to know: preventDefault stops the browser
// from reloading the page, so your JavaScript can handle the submission.
const form = document.querySelector("#new-task-form");
form.addEventListener("submit", (event) => {
event.preventDefault(); // without this, the page reloads and your code is lost
const input = form.querySelector("input");
console.log("Submitted:", input.value);
});
// You can remove a listener later — but only if you kept a reference to the
// same function you added. An inline arrow function cannot be removed.
function onKey(event) {
console.log("key:", event.key);
}
document.addEventListener("keydown", onKey);
document.removeEventListener("keydown", onKey);
event.preventDefault() on a form's submit is the single most important line in most interactive apps — it is what lets you handle a form in JavaScript instead of letting the browser navigate away. The capstone's form does exactly this.
Event Delegation
Suppose your list has fifty items, each with a Delete button, and you add more at runtime. Attaching a listener to every button is wasteful — and worse, a button you create later never got one. Event delegation solves both: attach a single listener to the parent, and rely on the fact that events bubble up from the element they started on through its ancestors.
Inside the handler, two properties matter and are easy to confuse:
event.target— the element the event actually started on (the specific button the user clicked).event.currentTarget— the element the listener is attached to (the parent).
You want event.target to find which child was clicked. Here is the real pattern:
const list = document.querySelector("#task-list");
// ONE listener on the parent covers every current AND future child.
list.addEventListener("click", (event) => {
// Find the button that was actually clicked (or bail if it was empty space).
const button = event.target.closest("button");
if (!button) return;
// Read which action and which item straight off the DOM.
const li = button.closest("li");
const id = li.dataset.taskId;
if (button.dataset.action === "delete") {
li.remove();
}
});
Because the listener lives on #task-list, it handles buttons inside <li>s that did not even exist when the listener was attached. That is why delegation and dynamic content go together.
Debug
Here is the delegation logic on its own — a handleClick router receiving an event-shaped object, where target is the clicked button and currentTarget is the list the listener is attached to (exactly what a real click event carries). It should return a delete message when a Delete button is clicked, but it ignores every click instead — nothing ever deletes. Commit a hypothesis about why, then fix it so both clicks are handled.
const assert = require('assert');
// An event-shaped object, like the one a real click listener receives:
// target is the button actually clicked; currentTarget is the list host.
function makeEvent(clickedId, action) {
return {
target: { id: clickedId, action: action },
currentTarget: { id: "task-list", action: undefined },
};
}
// Should delete the clicked task; instead it ignores every click.
function handleClick(event) {
if (event.currentTarget.action === "delete") {
return "deleted task on " + event.currentTarget.id;
}
return "ignored";
}
const first = handleClick(makeEvent("btn-3", "delete"));
const second = handleClick(makeEvent("btn-7", "delete"));
assert.strictEqual(first, "deleted task on btn-3", 'got ' + first);
assert.strictEqual(second, "deleted task on btn-7", 'got ' + second);
console.log(first);
console.log(second);Expected output: deleted task on btn-3
deleted task on btn-7
Try It Yourself
Time to write real DOM code. This is a small task list — add tasks with a form, click to delete them — built with everything above: querySelector, createElement, addEventListener, and one delegated listener on the list. It is a deliberate first step toward the capstone's Task Manager renderer, and it uses the same element ids (#new-task-form, #new-task-title, #task-list) so the muscle memory carries straight over.
Save this as index.html and open it in your browser. Add a few tasks, then delete one — notice that even tasks you just created respond to clicks, because the listener is on the list, not the buttons.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Task List</title>
<style>
body { font-family: system-ui, sans-serif; max-width: 30rem; margin: 2rem auto; padding: 0 1rem; }
#task-list { list-style: none; padding: 0; }
#task-list li { display: flex; align-items: center; gap: 0.5rem; padding: 0.5rem 0; border-bottom: 1px solid #ddd; }
.title { flex: 1; }
button { cursor: pointer; }
</style>
</head>
<body>
<h1>Task List</h1>
<form id="new-task-form">
<input id="new-task-title" type="text" placeholder="What needs doing?" autocomplete="off" />
<button type="submit">Add</button>
</form>
<ul id="task-list"></ul>
<script>
// Grab the elements once, up front.
const form = document.querySelector("#new-task-form");
const input = document.querySelector("#new-task-title");
const list = document.querySelector("#task-list");
// Build one <li> for a task title and append it to the list.
function addTask(title) {
const li = document.createElement("li");
const span = document.createElement("span");
span.className = "title";
span.textContent = title; // textContent, never innerHTML: title is user input
const deleteBtn = document.createElement("button");
deleteBtn.textContent = "Delete";
deleteBtn.dataset.action = "delete";
li.append(span, deleteBtn);
list.appendChild(li);
}
// Form submit: stop the reload, validate, add, clear.
form.addEventListener("submit", (event) => {
event.preventDefault();
const title = input.value.trim();
if (title === "") return; // ignore empty input
addTask(title);
input.value = "";
input.focus();
});
// ONE delegated listener handles Delete on every current and future task.
list.addEventListener("click", (event) => {
const button = event.target.closest("button");
if (!button) return;
if (button.dataset.action === "delete") {
button.closest("li").remove();
}
});
</script>
</body>
</html>
Read the flow once: the form's submit handler calls preventDefault, validates, and builds an <li>; the list's single click handler uses event.target to find the clicked button and removes its row. No per-button listeners anywhere — that is delegation earning its keep.
To convince yourself the DOM tree really changed (not just the pixels), the underlying operations are plain array-like list surgery — here is the same "remove item 2 from a list" logic as pure values, which does run on this page:
// The DOM's create/append/remove mirror ordinary list operations.
const items = ["Buy groceries", "Walk the dog", "Write code"];
// "append" a new item
items.push("Read a book");
console.log(items); // [ 'Buy groceries', 'Walk the dog', 'Write code', 'Read a book' ]
// "remove" the item at index 1 (Walk the dog)
items.splice(1, 1);
console.log(items); // [ 'Buy groceries', 'Write code', 'Read a book' ]
console.log("remaining:", items.length); // remaining: 3
In the browser, list.appendChild(li) and li.remove() do the same thing to the real element tree — and the browser repaints to match.
One more, this time asserted
The task list above is the real thing, and you run it in your own browser — the DOM does not exist in the in-page runner, so it cannot check itself here. But the logic underneath a renderer is plain JavaScript: matching a clicked element, counting what each action would do, and building the HTML string a renderer sets. That logic is node-safe, so here it can report its own pass/fail.
This is a build task, the same shape as the debug block earlier: elements and events are modelled as plain objects — a clicked "element" is { tag, id, action, label }, exactly the fields the delegation code reads off event.target, with no document in sight. Three functions are stubbed; finish each until every check passes and it prints All checks passed. Each function is the pure core of something the real renderer does in the browser above.
Build
Finish the build. Three functions are stubbed out and the checks below them fail until each returns the right value. Run it as-is to see which check fails first, decide what that function is missing, then implement all three until it prints 'All checks passed.' The checks run top to bottom, so the first failure you see is TODO 1 — implement it first, then work down. Everything is plain objects and strings: no document, no DOM APIs, just the logic a renderer runs.
const assert = require('assert');
// Plain objects standing in for the DOM. Each "element" is just an object
// with a tag, an id, an action, and a label — no document, no browser.
// The click targets are the elements a delegated listener would receive as
// event.target. Do NOT change this data.
const clickTargets = [
{ tag: "button", id: "btn-1", action: "delete", label: "Delete" },
{ tag: "span", id: "title-1", action: undefined, label: "Buy milk" },
{ tag: "button", id: "btn-2", action: "delete", label: "Delete" },
{ tag: "button", id: "btn-3", action: "toggle", label: "Done" },
{ tag: "button", id: "btn-4", action: "delete", label: "Delete" },
];
const tasks = [
{ id: 1, title: "Buy milk", done: false },
{ id: 2, title: "Walk dog", done: true },
];
// TODO 1: the delegation matcher — the string check a delegated listener does
// before acting. Return true only when the target is a button carrying the
// given action. Reads target.tag and target.action.
// matches({ tag: "button", action: "delete" }, "delete") -> true
// matches({ tag: "span", action: undefined }, "delete") -> false
function matches(target, action) {
// your code here
}
// TODO 2: reduce the click targets to a count of how many buttons carry each
// action, skipping any target that is not a button. Return an object
// mapping action name to its count.
// countActions(clickTargets) -> { delete: 3, toggle: 1 }
function countActions(clickTargets) {
// your code here
}
// TODO 3: render the task list to the HTML string a renderer would set — one
// <li> per task holding its title, all wrapped in a <ul>, no separators.
// renderToString(tasks) -> "<ul><li>Buy milk</li><li>Walk dog</li></ul>"
function renderToString(tasks) {
// your code here
}
// --- Build checks: these must all pass. Do not edit below this line. ---
assert.strictEqual(
matches({ tag: "button", action: "delete" }, "delete"),
true,
"matches() should be true for a button carrying the requested action",
);
assert.strictEqual(
matches({ tag: "span", action: undefined }, "delete"),
false,
"matches() should be false for a non-button or a wrong action",
);
assert.deepStrictEqual(
countActions(clickTargets),
{ delete: 3, toggle: 1 },
"countActions() should count how many buttons carry each action",
);
assert.strictEqual(
renderToString(tasks),
"<ul><li>Buy milk</li><li>Walk dog</li></ul>",
"renderToString() should build the <ul> of <li> task titles as a string",
);
console.log("All checks passed.");
console.log("Delete matches:", matches({ tag: "button", action: "delete" }, "delete"));
console.log("Action counts:", countActions(clickTargets));
console.log("Rendered HTML:", renderToString(tasks));Expected output: All checks passed.
Delete matches: true
Action counts: { delete: 3, toggle: 1 }
Rendered HTML: <ul><li>Buy milk</li><li>Walk dog</li></ul>
Once it passes, try two variations and predict each before running:
- Drop the non-button guard. In
countActions, remove the check that skips any target whosetagis not"button", so every target gets counted, then predict the result. The span slips through and itsundefinedaction becomes a key: the count is{ delete: 3, undefined: 1, toggle: 1 }, so thedeepStrictEqualfails against{ delete: 3, toggle: 1 }. An instructive assert failure showing exactly what theif (!button) returndelegation guard keeps out. - Add a stray space to the markup. In
renderToString, append a space after each</li>("<li>" + task.title + "</li> ") and predict the output. The result becomes<ul><li>Buy milk</li> <li>Walk dog</li> </ul>, which fails the exact-string assert — the renderer's output has to match byte-for-byte, and a delegated listener reading that markup can't have surprise whitespace. An instructive assert failure about exact string output.
Capstone milestone
Milestone — the renderer. The capstone's Task Manager needs a renderer that turns a list of tasks into real <li> elements and reacts to clicks. This lesson's task list is that renderer in miniature. Confirm you built it for real.
Hint: You do not need the full capstone yet — this confirms the create-elements-and-delegate-events skill the renderer milestone is built on. In the capstone it grows a stats line, a Done button, and localStorage persistence.
- Selected elements with document.querySelector on #new-task-form, #new-task-title, and #task-list
- Built each task with document.createElement and set its text with textContent (never innerHTML)
- The form's submit handler calls event.preventDefault() so the page does not reload
- A SINGLE delegated click listener on #task-list handles Delete for every current and future task
Key Takeaways
- The DOM is a live tree of objects representing the HTML page; JavaScript reads and modifies it and the browser repaints to match.
- Select elements with
document.querySelector/querySelectorAllusing CSS selectors — and rememberquerySelectorreturnsnullwhen nothing matches. - Modify elements with
textContent,innerHTML,style,classList, anddataset— usetextContentfor anything a user typed. - Create elements with
document.createElement, insert withappendChild/append, and remove with.remove(). - Handle events with
addEventListener; on forms,event.preventDefault()stops the browser from reloading. - Event delegation puts one listener on a parent and uses
event.target(notevent.currentTarget) to find the child that was actually clicked — so it covers dynamically added elements for free.
Next Steps
You now write real DOM code: selecting, modifying, creating, and reacting to user events. The next lesson covers local storage and state — how to make the data behind a page like your task list survive a reload, so the app remembers what the user did between visits. That persistence layer is the piece your task list is still missing.
Pro Tip: The DOM advice you will hear most often — "build it in memory, insert it once, it's faster" — does not hold up when you measure it. Appending 2000
<li>s one at a time to the live page and building them detached first both cost about 0.5 ms of scripting in Chrome — and about 30 ms each once the layout and paint that follow are counted, which is to say the same, within noise. The browser queues your changes and puts off recalculating layout until something forces it. What forces it is reading a layout value back. Add oneul.offsetHeightread inside that same loop and that 0.5 ms of scripting becomes about 250 ms — roughly 500 times slower — because every read makes the browser stop and lay out everything you just wrote. So the habit worth forming is not "batch your writes", it is keep reads and writes apart: measure everything you need first (offsetWidth,getBoundingClientRect,getComputedStyle), then do all your writing. Building in memory is still fine — just choose it because it reads more clearly, not because you expect it to be faster.
Next lesson
Local Storage and State
Learn localStorage and sessionStorage to persist data in the browser. Manage simple application state without a framework.
25 min