The DOM is the tree of objects the browser turns HTML into: JavaScript finds elements in it with document.querySelector, changes their text (textContent), classes (classList) and attributes, and creates new ones with createElement. You react to user actions with addEventListener; an event bubbles from the element up to its parents, so one listener on a list can serve all its buttons — this is called event delegation. A form is handled in the submit event: event.preventDefault() cancels the page reload and new FormData(form) collects the field values.
After this you will be able to
- Find elements with
querySelectorandquerySelectorAlland explain whennullcomes back - Change an element's text, classes and attributes, create and remove elements without using
innerHTMLfor user input - Listen for events with
addEventListener, explain bubbling and the difference betweentargetandcurrentTarget - Handle a form with
submit,preventDefaultandFormDataand serve a list's buttons with one delegated listener
Know beforehand
Verified with
DOM Living StandardJavaScript (ES2024)Microsoft Edge 155 + Playwright 1.64ESLint 10.12
Checked against docs: October 11, 2026
Contents
- The DOM: the document tree
- Finding elements: querySelector and querySelectorAll
- Changing the page: textContent, classList, attributes, new elements
- Events: addEventListener and the event object
- Bubbling and delegation
- Forms: submit, preventDefault and FormData
- Practice: a task list without a framework
- Exercises and next steps
The DOM: the document tree
When the browser loads HTML, it builds the DOM (Document Object Model) in memory: a tree of nodes in which every tag becomes an object. MDN calls the DOM a programming interface for the document: through it a script can change the page's structure, styles and content. The HTML file itself stays the same; what changes is its live copy in the browser.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>DOM: first steps</title>
<script src="select.js" defer></script>
</head>
<body>
<h1>Today's tasks</h1>
<ul id="task-list">
<li class="task">Buy coffee beans</li>
<li class="task done">Clean the coffee machine</li>
</ul>
<p id="status" hidden>All tasks are done</p>
<button type="button" id="add">Add a task</button>
</body>
</html>- The root of the tree is
document. Below it arehtml, thenheadandbody, then everything inside. ul#task-listhas two childlielements, and eachlihas a text node with the task name.- The script is included in
headwith thedeferattribute: according to MDN, such a script runs after the whole HTML is parsed, in the order scripts appear. Withoutdefera script inheadwould run before the browser reachedbodyand would find no elements.
Run the code below in the DevTools console on this page or include it instead of select.js. The output in the // → comments is real, from Edge 155.
Finding elements: querySelector and querySelectorAll
Both methods take a CSS selector, the same one you write in stylesheets. querySelector returns the first matching element, or null if there is none. querySelectorAll returns a NodeList with all matching elements; according to MDN it is static, so if you later add another .task to the page, the list you already have will not change.
// The first matching element, or null if there is none
const title = document.querySelector("h1");
console.log(title.textContent); // → Today's tasks
console.log(document.querySelector(".missing")); // → null
// All matching elements: a static NodeList
const tasks = document.querySelectorAll("#task-list .task");
console.log(tasks.length); // → 2
tasks.forEach((task) => console.log(task.textContent)); // → Buy coffee beans, then Clean the coffee machine
// From an element you can walk up and down the tree
const list = tasks[0].parentElement;
console.log(list.id, list.children.length); // → task-list 2
console.log(document.querySelector(".done").textContent); // → Clean the coffee machine"h1"
- What it finds
- An element by tag name
"#task-list"
- What it finds
- The element with
id="task-list"
".task"
- What it finds
- Elements with the
taskclass
"#task-list .task"
- What it finds
.taskelements inside#task-list
"button[data-action]"
- What it finds
- Buttons with a
data-actionattribute
"li:not(.done)"
- What it finds
lielements without thedoneclass
Changing the page: textContent, classList, attributes, new elements
const list = document.querySelector("#task-list");
const [first, second] = list.querySelectorAll(".task");
// Text
first.textContent = "Buy espresso beans";
console.log(first.textContent); // → Buy espresso beans
// Classes
first.classList.add("urgent");
second.classList.remove("done");
console.log(first.className); // → task urgent
console.log(second.classList.toggle("done")); // → true
console.log(second.classList.contains("done")); // → true
// Attributes and data attributes
first.setAttribute("title", "Due today");
first.dataset.priority = "high"; // in HTML: data-priority="high"
console.log(first.outerHTML); // → <li class="task urgent" title="Due today" data-priority="high">Buy espresso beans</li>
// The hidden property shows and hides an element
const statusNote = document.querySelector("#status");
statusNote.hidden = false;
// A new element: create, fill, insert
const item = document.createElement("li");
item.className = "task";
item.textContent = "Call the technician";
list.append(item);
console.log(list.children.length); // → 3
// textContent does not parse HTML: markup from input stays text
const userInput = "<b>Urgent</b>";
item.textContent = userInput;
console.log(item.children.length); // → 0
item.innerHTML = userInput; // never do this with user input
console.log(item.children.length); // → 1
item.remove();
console.log(list.children.length); // → 2Read or replace text
- Property or method
element.textContent
Add, remove, toggle a class
- Property or method
classList.add(),remove(),toggle(),contains()
An attribute
- Property or method
setAttribute(),getAttribute(),removeAttribute()
Your own data in markup
- Property or method
data-*in HTML ↔element.datasetin JS
Show or hide
- Property or method
element.hidden = true / false
Create and insert
- Property or method
document.createElement(), thenappend()
Remove
- Property or method
element.remove()
Note classList.toggle: it not only toggles the class but also returns true if the class is now present, which comes in handy in the practice section. Styles still live in CSS: the script adds the done class, and the .done rule in the stylesheet decides what a finished task looks like.
Events: addEventListener and the event object
An event is the browser's signal that something happened: a click, a keystroke, a form submission, a page load. To react, you attach a listener to an element: a function the browser calls when the event happens. Functions and callbacks are covered in the functions lesson; here the callback is simply the second argument of addEventListener.
const button = document.querySelector("#add");
let clicks = 0;
button.addEventListener("click", (event) => {
clicks++;
console.log(event.type, event.target.id, clicks); // → click add 1, after the second click — click add 2
});
// This listener runs once and removes itself
button.addEventListener("click", () => console.log("first click"), { once: true });The browser passes the listener an event object. It holds type, the event type; target, the element the event happened on; currentTarget, the element the listener is attached to; and preventDefault(), which cancels the browser's default action. The { once: true } option removes the listener after its first run: in our check "first click" was printed once for two clicks.
click
- When it happens
- A mouse click, a tap, and for buttons also Enter and Space
input
- When it happens
- Every change of a field's value while typing
change
- When it happens
- A field's value is committed: an option is selected, focus leaves
submit
- When it happens
- A form is submitted by a button or by Enter in a field; it fires on the
form, not on the button
keydown
- When it happens
- A key is pressed
DOMContentLoaded
- When it happens
- The HTML is parsed and
deferscripts have run
To remove a listener you need removeEventListener with the very same function. An anonymous arrow function written inline in the call is not reachable from outside, so store a listener you will need to remove in a variable.
Bubbling and delegation
A click on an element is also a click on all its parents. MDN describes this as bubbling: the event fires first on the innermost element, then on its parent, and so on up to document. Let's attach the same listener to an li, the ul and body:
function logClick(event) {
console.log(`${event.currentTarget.tagName}: target = ${event.target.tagName}`);
}
document.body.addEventListener("click", logClick);
document.querySelector("#task-list").addEventListener("click", logClick);
document.querySelector(".task").addEventListener("click", logClick);
// A click on the first task prints:
// LI: target = LI
// UL: target = LI
// BODY: target = LItarget is LI at all three levels: it is the element that was clicked. currentTarget changes: it is the element whose listener is running right now. You can stop bubbling with event.stopPropagation(), but do it rarely: listeners higher up the tree, such as analytics or closing a menu on an outside click, will stop hearing about the event.
The main benefit of bubbling is delegation. Instead of a listener on every button you attach one to the shared container and use event.target to find out what was clicked. The upsides: one listener no matter how many elements there are, and it works for elements added later without wiring anything up. closest(selector) walks up from target and finds the nearest matching element, so a click on an icon inside a button still counts for the button.
- 01Click on "Done"
- 02button
- 03li
- 04ul: listener
- 05closest() → action
Forms: submit, preventDefault and FormData
A form is handled in the submit event on the form itself, not in click on the button: submit fires both on a click and on Enter in a field. By default the browser submits the form and reloads the page. event.preventDefault() cancels that, and the script takes over. new FormData(form) collects the values of all fields by their name attributes, which is why name is required in the markup.
form.addEventListener("submit", (event) => {
event.preventDefault(); // no page reload
const data = new FormData(form);
const title = String(data.get("title")).trim();
if (title === "") return; // whitespace only
list.append(createTask(title));
form.reset();
input.focus();
renderCounter();
});data.get("title")returns the value of thename="title"field.trim()strips spaces at the edges so a task " " does not get into the list.- An empty field never even reaches the listener: the
inputhas therequiredattribute, and the browser does not submit the form.requiredlets a whitespace-only string through; our check filters it out. form.reset()clears the fields andinput.focus()puts the cursor back so you can type the next task straight away.
Practice: a task list without a framework
Let's build a mini app: a field and a button add a task, "Done" marks it complete, "Delete" removes it, and a counter shows how many are left. The two buttons on every task are served by one listener on the list.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Task list</title>
<style>
.done span { text-decoration: line-through; color: #6b7280; }
</style>
<script src="app.js" defer></script>
</head>
<body>
<main>
<h1>Task list</h1>
<form id="task-form">
<label for="task-title">New task</label>
<input id="task-title" name="title" type="text" autocomplete="off" required>
<button type="submit">Add</button>
</form>
<ul id="task-list"></ul>
<p id="task-counter" aria-live="polite"></p>
</main>
</body>
</html>const form = document.querySelector("#task-form");
const input = document.querySelector("#task-title");
const list = document.querySelector("#task-list");
const counter = document.querySelector("#task-counter");
function renderCounter() {
const left = list.querySelectorAll("li:not(.done)").length;
counter.textContent = `Tasks left: ${left}`;
}
function createTask(title) {
const item = document.createElement("li");
const text = document.createElement("span");
text.textContent = title; // user input goes in as text only
const toggle = document.createElement("button");
toggle.type = "button";
toggle.dataset.action = "toggle";
toggle.setAttribute("aria-pressed", "false");
toggle.textContent = "Done";
const remove = document.createElement("button");
remove.type = "button";
remove.dataset.action = "remove";
remove.textContent = "Delete";
item.append(text, " ", toggle, " ", remove);
return item;
}
form.addEventListener("submit", (event) => {
event.preventDefault(); // no page reload
const data = new FormData(form);
const title = String(data.get("title")).trim();
if (title === "") return; // whitespace only
list.append(createTask(title));
form.reset();
input.focus();
renderCounter();
});
// One listener for the whole list: event delegation
list.addEventListener("click", (event) => {
const button = event.target.closest("button[data-action]");
if (!button) return; // the click missed the buttons
const item = button.closest("li");
if (button.dataset.action === "toggle") {
const isDone = item.classList.toggle("done");
button.setAttribute("aria-pressed", String(isDone));
}
if (button.dataset.action === "remove") {
item.remove();
}
renderCounter();
});
renderCounter();Open index.html
By double-clicking or through a local server. The counter immediately shows "Tasks left: 0" because
renderCounter()runs on load.Add tasks
Type some text and press Enter or "Add". The page does not reload, the field is cleared and keeps focus.
Mark and delete
"Done" strikes the task through and decrements the counter; pressing it again unmarks it. "Delete" removes the row.
Try to break it
Submit an empty field, a whitespace-only string and the text
<b>Urgent</b>: the first two are not added, the third appears as text rather than in bold.
We ran this scenario automatically in Edge 155: three tasks were added, the empty and whitespace-only ones were rejected, the page never reloaded, <b> did not become an element, the buttons work with the mouse and with Space on the keyboard, and aria-pressed on "Done" toggles together with the class. Through the DevTools protocol we also checked the delegation itself: there is one click listener, on the ul, and the buttons have none of their own.
Exercises and next steps
So far all the data lives in the page itself. The next lesson is asynchronous JavaScript: loading a list from a server with fetch, showing loading and error states, and not blocking the interface. When you have many interfaces like this, it is easier to build them from components, which is what the React lessons cover. This lesson's technique also shows up in the debounce recipe: an input listener on a search field fires on every keystroke.
How this was verified4 · passedchecked 11 October 2026
“Run” means the code was executed in a scratch project or container and the output in the article comes from that run. “Partial” means it was checked against stubs instead of external services. “Not run” means it needs a device, an account or a key; the steps were checked against the official documentation. The verification project files and run logs are kept in the InterLAB archive.