The DOM and Events

Table of Contents

  1. The DOM Is a Tree You Can Change
  2. Finding Elements
  3. Changing Text Safely
  4. Creating and Adding Elements
  5. Classes and Data Attributes
  6. Events
  7. Event Delegation: A Filterable Project List
  8. Forms and preventDefault
  9. A Dark-Mode Toggle with localStorage
  10. Try It Yourself

1. The DOM Is a Tree You Can Change

In How the Web Works and again in the HTML series, you saw that the browser turns HTML text into a tree of elements called the DOM (Document Object Model). Until now you only looked at that tree in DevTools. JavaScript can also read and change it, and the browser repaints the page right away.

Two objects start everything:

  • document is the whole page. document.body is the <body> element.
  • Every element is an object. It has properties such as textContent and methods such as append.

This is why the DOM matters: it is how a static page becomes an interactive one. You will need index.html open with Live Server, with a module script loaded as in the last chapter:

<script type="module" src="js/main.js"></script>

2. Finding Elements

To change an element, first find it. Use CSS selectors, the same ones from the stylesheet chapters:

const title = document.querySelector("h1");
const cards = document.querySelectorAll(".project-card");
MethodReturns
querySelector(selector)The first match, or null if nothing matches
querySelectorAll(selector)A list of all matches (empty if none)

The list from querySelectorAll works with for...of, and you can also turn it into a real array with [...cards].

for (const card of cards) {
  console.log(card.textContent);
}

If a selector matches nothing, querySelector gives you null, and using it causes the error Cannot read properties of null. When you see that, check for a typo in the selector, or for a script that runs before the element exists.

3. Changing Text Safely

Two properties change what an element contains:

title.textContent = "Hello, I'm Dara";      // plain text
title.innerHTML = "Hello, <em>Dara</em>";   // parsed as HTML

textContent treats everything as text. innerHTML treats it as HTML, so tags become real elements.

Warning. Never put text from a user, a URL, or an API into innerHTML. If someone types <img src=x onerror="alert('hacked')"> and you insert it with innerHTML, the browser runs that code. This attack is called XSS (cross-site scripting), and it can steal data from other visitors. The safe rule:

Use textContent for any text you did not write yourself.

Use innerHTML only with fixed strings you wrote in your own code, and even then, prefer the next section.

4. Creating and Adding Elements

To build new elements, create them, fill them, then add them to the page:

const card = document.createElement("article");
card.className = "project-card";

const heading = document.createElement("h3");
heading.textContent = "Budget Tracker";

card.append(heading);
document.querySelector("#project-list").append(card);

append adds to the end of a parent. It accepts several items and plain strings. Other useful methods: prepend (add to the start), remove() (delete an element), and replaceChildren() (empty a parent, or replace all of its children at once).

Because the new element is built from createElement and textContent, no text is ever treated as HTML, so it is safe even for data you do not control.

5. Classes and Data Attributes

Instead of setting colors from JavaScript, change a class and let CSS do the styling. Keep looks in CSS, behavior in JavaScript:

card.classList.add("featured");
card.classList.remove("featured");
card.classList.toggle("featured");           // add if missing, remove if present
card.classList.contains("featured");         // true or false

HTML elements can also carry your own data in data- attributes, which JavaScript reads through dataset:

<button data-filter="javascript">JavaScript</button>
const button = document.querySelector("button");
console.log(button.dataset.filter); // "javascript"

A name like data-project-id becomes dataset.projectId: dashes turn into camelCase. All values are strings.

6. Events

An event is something that happens: a click, a key press, a form submit. You react with addEventListener, giving the event name and a function to call:

const themeButton = document.querySelector("#theme-toggle");

themeButton.addEventListener("click", () => {
  console.log("Clicked!");
});

The browser calls your function and passes it an event object with details:

PropertyMeaning
event.typeThe event name, for example "click"
event.targetThe element that was actually clicked
event.currentTargetThe element the listener is attached to
event.keyFor keyboard events, the key pressed
event.preventDefault()Stop the browser's default action

Common events: click, input (typing in a field), submit, keydown, change.

7. Event Delegation: A Filterable Project List

Now build the first feature. In index.html, add filter buttons and an empty list:

<section id="projects">
  <h2>Projects</h2>
  <div id="filters">
    <button type="button" data-filter="all">All</button>
    <button type="button" data-filter="html">HTML</button>
    <button type="button" data-filter="css">CSS</button>
    <button type="button" data-filter="javascript">JavaScript</button>
  </div>
  <div id="project-list"></div>
</section>

In js/main.js, import the data from the last chapter and draw the list:

import { projects } from "./projects.js";
import { filterByTag } from "./helpers.js";

const filters = document.querySelector("#filters");
const list = document.querySelector("#project-list");

function createCard(project) {
  const card = document.createElement("article");
  card.className = "project-card";

  const heading = document.createElement("h3");
  heading.textContent = project.title;

  const details = document.createElement("p");
  details.textContent = `${project.year} · ${project.tags.join(", ")}`;

  card.append(heading, details);
  return card;
}

function render(items) {
  list.replaceChildren(...items.map(createCard));
}

render(projects);

To respond to clicks, you could add a listener to each button. A better pattern is event delegation: add one listener on the parent. Clicks on children bubble up to it, and event.target tells you which child was clicked.

filters.addEventListener("click", (event) => {
  const button = event.target.closest("button[data-filter]");
  if (button === null) return;

  const tag = button.dataset.filter;
  render(tag === "all" ? projects : filterByTag(projects, tag));
});

closest walks up from the clicked element to the nearest match, and returns null if the click was not on a filter button. Delegation has a real advantage: if you add more buttons later, or create them with JavaScript, they work with no extra code.

8. Forms and preventDefault

When a form is submitted, the browser's default action is to send the data and reload the page. To handle it yourself, listen for submit on the form (not the button) and call preventDefault():

<form id="contact-form" novalidate>
  <label>Email <input type="email" name="email" required></label>
  <p id="form-message" role="status"></p>
  <button type="submit">Send</button>
</form>
const form = document.querySelector("#contact-form");
const message = document.querySelector("#form-message");

form.addEventListener("submit", (event) => {
  event.preventDefault();

  const data = new FormData(form);
  const email = String(data.get("email")).trim();

  if (!email.includes("@")) {
    message.textContent = "Please enter a valid email address.";
    return;
  }
  message.textContent = "Thanks! Your message is ready to send.";
});

FormData reads every field by its name. The novalidate attribute turns off the browser's own popup, so your message shows instead. This simple check is only a start; the form validator you will refine later builds on it.

9. A Dark-Mode Toggle with localStorage

The second feature stores a choice in the browser. localStorage keeps small pieces of text on the visitor's computer, and the text survives closing the tab. Add a button to the header:

<button type="button" id="theme-toggle" aria-pressed="false">Dark mode</button>

In CSS, define colors as variables and override them when the root element has data-theme="dark":

:root {
  --bg: #fdf6e3;
  --text: #333333;
}

:root[data-theme="dark"] {
  --bg: #1b1b1f;
  --text: #eeeeee;
}

body {
  background-color: var(--bg);
  color: var(--text);
}

Now the JavaScript. Choose the saved theme first, then the operating system's preference, then light:

const themeButton = document.querySelector("#theme-toggle");
const root = document.documentElement;

function applyTheme(theme) {
  root.dataset.theme = theme;
  themeButton.setAttribute("aria-pressed", String(theme === "dark"));
  localStorage.setItem("theme", theme);
}

const saved = localStorage.getItem("theme");
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
applyTheme(saved ?? (prefersDark ? "dark" : "light"));

themeButton.addEventListener("click", () => {
  applyTheme(root.dataset.theme === "dark" ? "light" : "dark");
});

getItem returns null when nothing is saved, and ?? falls back to the right-hand side only for null or undefined. Reload the page: the theme stays. Check Application → Local Storage in DevTools to see the saved value. Do not store passwords or private data there, because any script on the page can read it.

10. Try It Yourself

  1. Find and change. In the console, run document.querySelector("h1").textContent = "Test". Then try a selector that matches nothing and read the error.
  2. See XSS safely. On a practice page, set an element's innerHTML to <b>bold</b>, then set textContent to the same string. Compare what you see, and explain why textContent is safer.
  3. Build the filter. Add the buttons and render function, and make the filters work with one delegated listener. Mark the active button by toggling a class named active.
  4. Show a count. After each filter, update a <p id="count"> with text like 2 projects.
  5. Add dark mode. Build the toggle with localStorage. Reload, then clear the saved value in DevTools and reload again. What happens?
  6. Handle the form. Add the submit handler. Make an empty field show an error message, and show the success message only when the email contains @.

Next, we'll load data that is not in your files at all: asynchronous JavaScript, and fetching Dara's GitHub projects over the network.

Buy Me a Coffee

Found this tutorial helpful? Consider buying me a coffee to support more free content!