Week 10: The DOM & Runtime Basics

Nine weeks of JavaScript, and none of it has touched a web page yet. This week puts it to work: selecting and changing elements, reacting to clicks and input, and fetching real data from a real API. It closes with a quick look at what Node.js's runtime offers that a browser simply doesn't have — and vice versa.

Phase 6 of 8 Week 10 of 14 ~3–4 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Select, read and update elements on a page with the DOM API
  • Handle events efficiently with event delegation instead of one listener per element
  • Fetch and render real data from an API using fetch and async/await

1. Selecting & Updating Elements

The DOM (Document Object Model) is the browser's live, in-memory representation of the page — every HTML tag becomes an object you can read from and write to with JavaScript:

selecting elements
// querySelector returns the FIRST match, using CSS-selector syntax
const heading = document.querySelector("h1");
const firstButton = document.querySelector(".btn");

// querySelectorAll returns ALL matches, as a NodeList (array-like, not a real array)
const allButtons = document.querySelectorAll(".btn");
console.log(allButtons.length);
reading & updating elements
const heading = document.querySelector("h1");

heading.textContent = "Updated title";       // change the text
heading.classList.add("highlight");          // add a CSS class
heading.classList.remove("hidden");           // remove a CSS class
heading.classList.toggle("active");           // add if missing, remove if present
heading.style.color = "blue";                 // set an inline style directly
heading.setAttribute("data-id", "42");        // set any HTML attribute

querySelector/querySelectorAll accept the exact same selector syntax as CSS — a tag name, a .class, an #id, or any combination — which means anything you already know how to target in a stylesheet, you already know how to select in JavaScript. classList is almost always the right tool for changing appearance: toggling a CSS class is more maintainable than setting individual style properties by hand.

Prefer textContent over innerHTML for plain text

innerHTML parses its string as actual HTML — setting it from untrusted input (like something a user typed) opens the door to injecting scripts onto your own page. textContent always inserts a plain string safely, with no HTML parsing at all.

2. Handling Events

addEventListener registers a callback — a familiar idea from every earlier week — to run whenever a specific event happens on an element:

a basic click handler
const button = document.querySelector("#save-btn");

button.addEventListener("click", (event) => {
  console.log("Clicked!", event.target); // event.target is the element that was clicked
});
reading form input
const input = document.querySelector("#search");

input.addEventListener("input", (event) => {
  console.log("Current value:", event.target.value);
});

const form = document.querySelector("#signup-form");
form.addEventListener("submit", (event) => {
  event.preventDefault(); // stop the browser's default full-page reload on submit
  console.log("Form submitted without reloading the page");
});

The callback always receives an event object with details about what happened — event.target is the actual element the event fired on, and event.target.value is how you read the current text out of an input as the user types. event.preventDefault() stops the browser's own default behavior for that event — essential for forms, where the default is a full page reload that would otherwise wipe out any JavaScript-driven behavior.

3. Event Delegation

Attaching a separate listener to every single item in a list works, but breaks the moment new items are added dynamically — and it's wasteful for a large list. Event delegation solves both problems by taking advantage of a behavior called event bubbling: an event fired on an element also fires on each of its ancestors, all the way up.

one listener, instead of one per item
// HTML:
// <ul id="todo-list">
//   <li>Buy milk</li>
//   <li>Walk the dog</li>
// </ul>

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

list.addEventListener("click", (event) => {
  if (event.target.tagName === "LI") {
    event.target.classList.toggle("done"); // works even for <li>s added AFTER this ran
  }
});

The click actually fires on the specific <li>, then bubbles up through its parents — including #todo-list, where the one listener above catches it. Checking event.target tells you exactly which child was actually clicked. Because there's only one listener on the parent (which never gets removed or recreated), this pattern keeps working correctly even for list items added long after the listener was first attached.

4. fetch() & a Real API

fetch makes an HTTP request and returns a Promise — everything from Weeks 7 and 8 applies directly:

fetching & rendering real data
async function loadUsers() {
  const list = document.querySelector("#user-list");
  list.textContent = "Loading...";

  try {
    const response = await fetch("https://jsonplaceholder.typicode.com/users");

    if (!response.ok) {
      throw new Error(`Request failed: ${response.status}`);
    }

    const users = await response.json(); // parses the response body as JSON
    list.innerHTML = ""; // clear "Loading..."

    for (const user of users) {
      const item = document.createElement("li");
      item.textContent = user.name; // textContent -- safe, no HTML parsing
      list.appendChild(item);
    }
  } catch (err) {
    list.textContent = `Failed to load users: ${err.message}`;
  }
}

loadUsers();

Two things about fetch catch people off guard: first, await fetch(...) only rejects on a genuine network failure — a 404 or 500 response is still a "successful" fetch as far as the Promise is concerned, which is exactly why response.ok needs to be checked explicitly and thrown on manually. Second, the response body has to be read separately with await response.json() (itself another Promise) — parsing the body is a distinct async step from getting the response headers back.

5. What Node's Runtime Adds

JavaScript the language is identical in a browser and in Node — closures, Promises, classes, all of it work exactly the same. What differs is the runtime environment surrounding the language: the set of built-in objects and APIs available to it.

browser-only vs. Node-only
// Browser-only: document, window, DOM, localStorage -- none of this exists in Node
document.querySelector("h1"); // ReferenceError in Node: document is not defined

// Node-only: direct filesystem and process access -- none of this exists in a browser
const fs = require("fs");    // (or: import fs from "node:fs")
fs.readFileSync("data.txt", "utf8");
process.env.API_KEY;          // read an environment variable

A browser gives JavaScript a DOM to manipulate, but deliberately walls it off from the local filesystem and OS-level details, for obvious security reasons — a random website should never be able to read your files. Node removes the DOM entirely (there is no page to manipulate) but opens up direct filesystem, network-server, and process access instead, which is exactly why Node is used for servers and build tools rather than in-browser UI code.

6. Hands-on Exercise

Hands-on

Build a filterable, fetched todo list

Combine DOM updates, event delegation and a real fetch call in one small page.

Requirements:

  1. Create a minimal HTML page with an empty <ul id="todo-list"> and a text input #filter-input.
  2. On page load, fetch("https://jsonplaceholder.typicode.com/todos?_limit=15"), and render each todo as an <li> (use textContent, and add a CSS class if the fetched completed field is true).
  3. Use event delegation on #todo-list so clicking any <li> toggles a done class on it.
  4. Add an input listener on #filter-input that hides any <li> whose text doesn't include the typed value (case-insensitive), using classList to toggle a hidden class rather than removing elements.
  5. Wrap the fetch in try/catch and show an error message in the list if the request fails.
Hint

For step 4, list.querySelectorAll("li") combined with Array.from(...) and Week 5's forEach is a clean way to loop over every item and check each one's text against the filter.

7. Knowledge Check

Four quick questions. Expand each to check your answer.

Q1

Why is textContent generally safer than innerHTML when inserting data from an API or user input?

innerHTML parses whatever string it's given as real HTML, which means untrusted text containing a <script> tag or similar could actually execute on your page. textContent always inserts a plain string with no HTML parsing at all, so it can't be used to inject markup or scripts, regardless of what the string contains.

Q2

What makes event delegation work — why does a click on a child <li> get caught by a listener on its parent <ul>?

Event bubbling. A click event doesn't only fire on the exact element clicked — it also fires on every ancestor of that element, all the way up the DOM tree, unless something explicitly stops it. A listener on the <ul> catches the bubbled event, and event.target tells it exactly which <li> was the original source.

Q3

Does await fetch(url) throw an error if the server responds with a 404?

No — fetch's Promise only rejects on a genuine network-level failure (like no connection at all). A 404 or 500 response is still a "successful" HTTP exchange as far as fetch is concerned, so it resolves normally. Checking response.ok (or response.status) and throwing manually is the correct way to treat an error status code as a failure.

Q4

Why does document.querySelector("h1") throw a ReferenceError when run in Node instead of a browser?

document is part of the DOM, which is a browser-provided runtime API, not part of the JavaScript language itself. Node's runtime has no page to represent and doesn't provide document or window at all — it offers a different set of built-ins instead, like direct filesystem access, that a browser doesn't provide for security reasons.