Free Handbook · Runs in your browser

DOM, Events & the Browser

The DOM as a tree you can read and change, selecting and updating elements, the event model with bubbling and delegation, forms, localStorage, fetch from a page, and a complete tiny app — the bridge from language to web page.

0 / 111 lessons🔥 0 day streak
ShareXLinkedIn

Module 10 · what you'll be able to do

  • Select, create, change and remove elements
  • Attach event listeners and use delegation for lists
  • Read forms without a page reload
  • Persist state in localStorage and load data with fetch
  • Build a small working app with a render function and state
01

The DOM: a tree you can change

The browser parses HTML into a tree of objects — the Document Object Model — and hands it to JavaScript as document. Every tag is an element with properties (textContent, className, style) and methods. Change the tree and the page updates. That is all a web framework does underneath: compute what the tree should look like, then change it.

Running this module
The Run buttons on this site execute in a Web Worker, which has no page and therefore no document. Paste the code blocks below into the DevTools console (F12) on any page, or save the HTML example at the end as a file and open it. The pure-logic parts are runnable here.
htmlpage.html
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>DOM demo</title>
</head>
<body>
  <h1 id="title">Tasks</h1>
  <ul class="tasks">
    <li class="task" data-id="1">Learn the DOM</li>
    <li class="task done" data-id="2">Read Module 09</li>
  </ul>
  <button id="add">Add</button>

  <!-- scripts go last, or use defer, so the elements above exist when the code runs -->
  <script type="module" src="app.js"></script>
</body>
</html>
javascriptapp.js
// Selecting — querySelector takes any CSS selector
const title = document.querySelector("#title")            // one element (or null)
const tasks = document.querySelectorAll(".task")           // a static NodeList
const list = document.querySelector("ul.tasks")

console.log(title.textContent, tasks.length, list.children.length)

// Reading and changing
title.textContent = "My tasks"        // text only — safe with user input
title.style.color = "steelblue"
title.classList.add("big")            // classList: add / remove / toggle / contains
console.log(tasks[0].dataset.id, tasks[1].classList.contains("done"))

// Creating and inserting
const li = document.createElement("li")
li.className = "task"
li.dataset.id = "3"
li.textContent = "Ship it"
list.append(li)                        // also: prepend, before, after, replaceWith

// Removing
tasks[1].remove()

// Walking the tree
console.log(li.parentElement.tagName, li.previousElementSibling.textContent, list.firstElementChild === tasks[0])

// innerHTML parses HTML — never with untrusted strings (XSS). Use textContent, or build elements.
list.insertAdjacentHTML("beforeend", "<li class=\"task\">From HTML</li>")
javascriptEdit it. ⌘/Ctrl + Enter runs.
You should see
<h1>My tasks</h1>
<ul>
    <li class="task" data-id="1">Learn the DOM</li>
    <li class="task done" data-id="2">Read Module 09</li>
</ul>
&lt;script&gt;alert(1)&lt;/script&gt;
02

Events: listeners, bubbling, delegation

The browser turns everything the user does into events: click, input, submit, keydown, scroll. You attach a function with addEventListener; it receives an event object describing what happened. Events bubble up from the element to its ancestors, which is what makes delegation possible: one listener on the list handles clicks on every item, including items added later.

javascriptevents.js
const button = document.querySelector("#add")
const list = document.querySelector("ul.tasks")

// The basic listener
button.addEventListener("click", event => {
  console.log(event.type, event.target === button, event.clientX)
})

// Delegation: one listener for every current AND future <li>
list.addEventListener("click", event => {
  const li = event.target.closest("li.task")      // the item, even if a child span was clicked
  if (!li) return
  li.classList.toggle("done")
  console.log("toggled", li.dataset.id)
})

// Keyboard
document.addEventListener("keydown", event => {
  if (event.key === "Escape") console.log("close the dialog")
  if ((event.metaKey || event.ctrlKey) && event.key === "s") {
    event.preventDefault()                      // stop the browser's own Save dialog
    console.log("save")
  }
})

// Stop bubbling only when you mean it (it breaks delegation above you)
document.querySelector("#inner")?.addEventListener("click", e => e.stopPropagation())

// Once, and removal
const onFirst = () => console.log("only once")
button.addEventListener("click", onFirst, { once: true })
button.removeEventListener("click", onFirst)

// Custom events: components talking to each other
list.addEventListener("task:done", e => console.log("custom", e.detail))
list.dispatchEvent(new CustomEvent("task:done", { detail: { id: 1 }, bubbles: true }))
javascriptEdit it. ⌘/Ctrl + Enter runs.
You should see
li saw click
ul saw click — delegation handles it here
body saw click from li
---
li saw click
li stopped propagation
event.target vs currentTarget
target is where it happened (the innermost element); currentTarget is the element whose listener is running.
preventDefault()
Cancel the browser's built-in action: following a link, submitting a form, typing a character.
stopPropagation()
Stop bubbling to ancestors. Rarely needed; it silently breaks delegation and analytics listeners above.
Passive listeners
{ passive: true } on scroll/touch promises you will not call preventDefault, so the browser can scroll without waiting for your code.
03

Forms

htmlform.html
<form id="signup">
  <label>Email <input name="email" type="email" required></label>
  <label>Age <input name="age" type="number" min="13"></label>
  <label><input name="terms" type="checkbox" required> I agree</label>
  <select name="plan">
    <option value="free">Free</option>
    <option value="pro">Pro</option>
  </select>
  <button>Sign up</button>
  <p class="error" hidden></p>
</form>
javascriptform.js
const form = document.querySelector("#signup")
const errorEl = form.querySelector(".error")

form.addEventListener("submit", async event => {
  event.preventDefault()                              // no page reload
  if (!form.reportValidity()) return                  // built-in validation (required, type, min)

  const data = Object.fromEntries(new FormData(form)) // { email, age, terms: "on", plan }
  const payload = { ...data, age: Number(data.age), terms: data.terms === "on" }

  const button = form.querySelector("button")
  button.disabled = true
  errorEl.hidden = true
  try {
    const res = await fetch("/api/signup", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(payload),
    })
    if (!res.ok) throw new Error((await res.json()).message ?? `HTTP ${res.status}`)
    form.reset()
  } catch (err) {
    errorEl.textContent = err.message
    errorEl.hidden = false
  } finally {
    button.disabled = false
  }
})

// Live validation as the user types
form.email.addEventListener("input", e => {
  e.target.setCustomValidity(e.target.value.endsWith("@example.com") ? "Use a real address" : "")
})

Form fields are reachable as form.email by name. FormData gives you every field at once; convert types yourself — everything from a form is a string.

javascriptEdit it. ⌘/Ctrl + Enter runs.
You should see
{ ok: true, errors: {} }
{
  ok: false,
  errors: {
    email: 'Enter a valid email',
    age: 'Must be a whole number, 13 or more',
    terms: 'You must accept the terms'
  }
}
{ email: '[email protected]', age: 36, terms: true, plan: 'pro' }
04

localStorage, fetch and the browser APIs

localStorage keeps strings per origin, forever, synchronously — perfect for preferences and drafts, wrong for anything sensitive or large. fetch is the same as in Module 06, plus the browser's same-origin policy: a page can only read responses from its own origin unless the server sends CORS headers. The rest of the platform is huge; the table lists what you will reach for first.

javascriptstorage.js
// localStorage: strings only — JSON in, JSON out, and it can throw (private mode, quota)
const KEY = "app:prefs:v1"
function loadPrefs() {
  try { return JSON.parse(localStorage.getItem(KEY)) ?? { theme: "light" } } catch { return { theme: "light" } }
}
function savePrefs(prefs) {
  try { localStorage.setItem(KEY, JSON.stringify(prefs)) } catch { /* quota or disabled: fine, it is a cache */ }
}
const prefs = loadPrefs()
prefs.theme = "dark"
savePrefs(prefs)

// sessionStorage: same API, cleared when the tab closes
// Cookies: sent to the server on every request — for sessions, set by the server (HttpOnly)

// fetch from a page: relative URLs resolve against the page; credentials for same-origin cookies
const res = await fetch("/api/tasks", { credentials: "same-origin" })
const tasks = await res.json()

// Cross-origin: works only if the OTHER server allows it (Access-Control-Allow-Origin)
try {
  await fetch("https://api.example.com/data")
} catch (err) {
  console.log("CORS or network:", err.message)   // "Failed to fetch" — the browser blocked reading it
}

// Other APIs you will use early
const id = crypto.randomUUID()
await navigator.clipboard.writeText("copied")
history.pushState({}, "", "/tasks/1")             // change the URL without reloading (routers)
const params = new URLSearchParams(location.search)
console.log(params.get("page"), matchMedia("(prefers-color-scheme: dark)").matches)
requestAnimationFrame(() => console.log("next frame"))
new IntersectionObserver(entries => console.log(entries[0].isIntersecting)).observe(document.querySelector("#title"))
javascriptEdit it. ⌘/Ctrl + Enter runs.
You should see
{ theme: 'light' }
{ theme: 'dark', size: 14 } string
{ theme: 'light' }
js ?q=js&page=3&tag=dom https://example.com/search?q=js&page=3&tag=dom
NeedAPI
Remember a preferencelocalStorage (strings, ~5 MB, synchronous)
Store lots of structured data offlineIndexedDB (use a wrapper like idb)
Talk to your serverfetch; WebSocket / EventSource for live updates
Change the URL in a single-page apphistory.pushState + popstate
Run code off the main threadWorker (what runs the examples on this page)
Animate or measurerequestAnimationFrame, ResizeObserver, IntersectionObserver
Files and mediaFile, FileReader, <input type=file>, MediaDevices
05

A tiny app: state → render → events

Every interactive page is the same loop: keep the state in one place, write a render function that turns state into DOM, and make event handlers that change state and call render. Frameworks add efficiency (only update what changed) and structure (components), but the loop is this. Save it as an .html file and open it.

htmltodo.html
<!doctype html>
<meta charset="utf-8">
<title>Todo</title>
<style>
  body { font-family: system-ui; max-width: 420px; margin: 40px auto; }
  li.done span { text-decoration: line-through; color: #888; }
  li { display: flex; gap: 8px; align-items: center; padding: 4px 0; }
</style>

<form id="new"><input name="text" placeholder="What needs doing?" autofocus required> <button>Add</button></form>
<ul id="list"></ul>
<p id="count"></p>

<script type="module">
  // 1. State — the single source of truth, persisted
  const KEY = "todo:v1"
  let state = { tasks: [], nextId: 1 }
  try { state = JSON.parse(localStorage.getItem(KEY)) ?? state } catch {}

  function setState(patch) {
    state = { ...state, ...patch }                       // immutable update
    try { localStorage.setItem(KEY, JSON.stringify(state)) } catch {}
    render()
  }

  // 2. Render — state in, DOM out. Never read the DOM to find out what the state is.
  const list = document.querySelector("#list")
  const count = document.querySelector("#count")
  function render() {
    list.replaceChildren(...state.tasks.map(t => {
      const li = document.createElement("li")
      li.dataset.id = t.id
      li.className = t.done ? "done" : ""
      const box = Object.assign(document.createElement("input"), { type: "checkbox", checked: t.done })
      const span = Object.assign(document.createElement("span"), { textContent: t.text })
      const del = Object.assign(document.createElement("button"), { textContent: "×", type: "button" })
      li.append(box, span, del)
      return li
    }))
    const left = state.tasks.filter(t => !t.done).length
    count.textContent = `${left} of ${state.tasks.length} left`
  }

  // 3. Events — change state, never the DOM directly
  document.querySelector("#new").addEventListener("submit", e => {
    e.preventDefault()
    const text = e.target.text.value.trim()
    if (!text) return
    setState({ tasks: [...state.tasks, { id: state.nextId, text, done: false }], nextId: state.nextId + 1 })
    e.target.reset()
  })

  list.addEventListener("click", e => {
    const li = e.target.closest("li"); if (!li) return
    const id = Number(li.dataset.id)
    if (e.target.matches("button")) setState({ tasks: state.tasks.filter(t => t.id !== id) })
    else if (e.target.matches("input")) setState({ tasks: state.tasks.map(t => t.id === id ? { ...t, done: e.target.checked } : t) })
  })

  render()
</script>
javascriptEdit it. ⌘/Ctrl + Enter runs.
You should see
{
  tasks: [
    { id: 1, text: 'learn DOM', done: true },
    { id: 3, text: 'ship', done: false }
  ],
  nextId: 4
}
1 of 2 left
Your turn
Add a "clear-done" action. Then add a filter (all / active / done) to the state and a visibleTasks(state) selector.
JuniorWhat is event delegation and why use it?

Attaching one listener to a parent element and using event.target (with closest) to find which child was hit, instead of one listener per child. It handles elements added later, uses less memory, and survives re-rendering the list. It relies on bubbling, so stopPropagation in a child breaks it.

Mid-levelWhat happens between typing a URL and seeing the page?

DNS lookup, TCP + TLS handshake, HTTP request; the server responds with HTML. The browser parses HTML into the DOM, fetches CSS (render-blocking) and scripts (blocking unless defer/module), builds the CSSOM, computes layout, paints, and composites. Scripts run and can change the DOM, triggering re-layout. DOMContentLoaded fires when the HTML is parsed; load when images and subresources are done. Performance work is mostly about the critical path: less blocking CSS/JS, smaller HTML, fewer round trips.

Finish the JavaScript handbook, then get hired

Sit the exam for your certificate, run your resume through the ATS checker, and see the jobs that ask for exactly this.

Check my resume
Found this course useful? Share it.
ShareXLinkedIn

Comments

0

Join the conversation. Sign in to leave a comment — we'd love to hear your thoughts.