The DOM and Events
Table of Contents
- The DOM Is a Tree You Can Change
- Finding Elements
- Changing Text Safely
- Creating and Adding Elements
- Classes and Data Attributes
- Events
- Event Delegation: A Filterable Project List
- Forms and preventDefault
- A Dark-Mode Toggle with localStorage
- 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:
documentis the whole page.document.bodyis the<body>element.- Every element is an object. It has properties such as
textContentand methods such asappend.
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");
| Method | Returns |
|---|---|
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
textContentfor 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:
| Property | Meaning |
|---|---|
event.type | The event name, for example "click" |
event.target | The element that was actually clicked |
event.currentTarget | The element the listener is attached to |
event.key | For 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
- Find and change. In the console, run
document.querySelector("h1").textContent = "Test". Then try a selector that matches nothing and read the error. - See XSS safely. On a practice page, set an element's
innerHTMLto<b>bold</b>, then settextContentto the same string. Compare what you see, and explain whytextContentis safer. - Build the filter. Add the buttons and
renderfunction, and make the filters work with one delegated listener. Mark the active button by toggling a class namedactive. - Show a count. After each filter, update a
<p id="count">with text like2 projects. - Add dark mode. Build the toggle with
localStorage. Reload, then clear the saved value in DevTools and reload again. What happens? - 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!