Skip to lesson

learningjavascript.org / intermediate / 10-dom-and-events · lesson 12 of 25

TL;DR

Learn JavaScript DOM manipulation and event handling. Select elements, modify content, create dynamic pages, and respond to user actions.

Key concepts

  • JavaScript DOM manipulation
  • JavaScript events
  • addEventListener JS
  • DOM tutorial

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 querySelector and querySelectorAll
  • 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 playground block, and the // output comments 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 single index.html file 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);
Continue learning

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 — textContent vs innerHTML: textContent treats its value as literal text; innerHTML parses it as markup. Setting innerHTML from anything a user typed is a cross-site scripting (XSS) hole — a value containing an img tag with an error-handler attribute would execute its script. Rule of thumb: user input goes in with textContent, 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?

Continue learning

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

Continue learning

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>

Continue learning

Once it passes, try two variations and predict each before running:

  1. Drop the non-button guard. In countActions, remove the check that skips any target whose tag is not "button", so every target gets counted, then predict the result. The span slips through and its undefined action becomes a key: the count is { delete: 3, undefined: 1, toggle: 1 }, so the deepStrictEqual fails against { delete: 3, toggle: 1 }. An instructive assert failure showing exactly what the if (!button) return delegation guard keeps out.
  2. 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
Continue learning

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 / querySelectorAll using CSS selectors — and remember querySelector returns null when nothing matches.
  • Modify elements with textContent, innerHTML, style, classList, and dataset — use textContent for anything a user typed.
  • Create elements with document.createElement, insert with appendChild / 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 (not event.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 one ul.offsetHeight read 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.

Two-tier handoff: this document is the complete reading surface. Continue learning for stateful practice, progress, and real sandbox execution.