How to buildOctober 11, 20268 min read
JavaScript from scratchStep 6 of 9

JavaScript in the browser: the DOM and events

In short

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.

TutorialLevel: BeginnerJavaScriptPractice: ~45–90 min

After this you will be able to

  • Find elements with querySelector and querySelectorAll and explain when null comes back
  • Change an element's text, classes and attributes, create and remove elements without using innerHTML for user input
  • Listen for events with addEventListener, explain bubbling and the difference between target and currentTarget
  • Handle a form with submit, preventDefault and FormData and serve a list's buttons with one delegated listener

Verified with

DOM Living StandardJavaScript (ES2024)Microsoft Edge 155 + Playwright 1.64ESLint 10.12

Checked against docs: October 11, 2026

Contents
  1. 01The DOM: the document tree
  2. 02Finding elements: querySelector and querySelectorAll
  3. 03Changing the page: textContent, classList, attributes, new elements
  4. 04Events: addEventListener and the event object
  5. 05Bubbling and delegation
  6. 06Forms: submit, preventDefault and FormData
  7. 07Practice: a task list without a framework
  8. 08Exercises 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.

basics.html
<!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 practice page: the examples in the next sections run on it.
  • The root of the tree is document. Below it are html, then head and body, then everything inside.
  • ul#task-list has two child li elements, and each li has a text node with the task name.
  • The script is included in head with the defer attribute: according to MDN, such a script runs after the whole HTML is parsed, in the order scripts appear. Without defer a script in head would run before the browser reached body and 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.

select.js
// 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
Finding by tag, class and id, looping over a list and moving to the parent.

"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 task class

"#task-list .task"

What it finds
.task elements inside #task-list

"button[data-action]"

What it finds
Buttons with a data-action attribute

"li:not(.done)"

What it finds
li elements without the done class

Changing the page: textContent, classList, attributes, new elements

change.js
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);                 // → 2
Text, classes, attributes, hidden, creating and removing elements.

Read 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.dataset in JS

Show or hide

Property or method
element.hidden = true / false

Create and insert

Property or method
document.createElement(), then append()

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.

events.js
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 });
A click listener and a listener with the once option. Include it instead of select.js and press the button twice.

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 defer scripts 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:

bubbling.js
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 = LI
The output in the comment is the real firing order in Edge 155.

target 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.

  1. 01Click on "Done"
  2. 02button
  3. 03li
  4. 04ul: listener
  5. 05closest() → action
The event bubbles from the button to the list, where a single listener catches it.

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.

app.js
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();
});
A fragment of app.js from the practice below: the form submit handler.
  • data.get("title") returns the value of the name="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 input has the required attribute, and the browser does not submit the form. required lets a whitespace-only string through; our check filters it out.
  • form.reset() clears the fields and input.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.

index.html
<!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>
The markup: a labelled form, an empty list and a counter. aria-live="polite" asks screen readers to announce counter changes.
app.js
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();
The whole app: creating a task, submitting the form, delegated clicks and the counter.
  1. Open index.html

    By double-clicking or through a local server. The counter immediately shows "Tasks left: 0" because renderCounter() runs on load.

  2. Add tasks

    Type some text and press Enter or "Add". The page does not reload, the field is cleared and keeps focus.

  3. Mark and delete

    "Done" strikes the task through and decrements the counter; pressing it again unmarks it. "Delete" removes the row.

  4. 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.

Tell us about your task — we'll review your request and get back to you

Got a project?
We'll scope and estimate it — for free.

Tell us about the product or the problem. After reviewing the task we'll come back with an architecture, a timeline and a price for the first version.

  • Scope and risks estimated before we start
  • NDA — yours or ours
  • Work starts within a week

Please do not send passwords, access keys or source code through this form — we will agree a safe way to share access separately.

By clicking the button you agree to the processing of your personal data.