What the DOM is

When a browser loads a web page, it parses the HTML into the Document Object Model (DOM): a tree of objects in memory that represents the page. What you see on screen is drawn from that tree, not from the HTML text. Change the tree with JavaScript and the page changes; the HTML file on the server stays the same.

Every item in the tree is a DOM node: an object for one part of the document, with links to its parent, its children and its siblings. The kinds you work with most are element nodes (one per HTML tag, such as <h1> or <li>) and text nodes (the text inside elements). At the top sits the document object, the entry point for everything on this page. The examples below use this small page of open outage tickets:

<!doctype html>
<html lang="en">
  <head>
    <title>Open outages</title>
    <script src="outages.js" defer></script>
  </head>
  <body>
    <h1>Open outages</h1>
    <p id="status">2 open tickets</p>
    <ul id="tickets">
      <li data-id="INC-30412">Portal login slow <button class="close">Close</button></li>
      <li data-id="INC-30415">Meter API timeouts <button class="close">Close</button></li>
    </ul>
    <form id="new-ticket">
      <label>Summary <input name="summary" required></label>
      <button>Add ticket</button>
    </form>
  </body>
</html>
Part of the DOM tree of the page: document contains html, which contains head (with title) and body. body contains h1, with the text node Open outages, and ul#tickets, which contains li elements. document html head body title h1 ul#tickets "Open outages" li … element node text node
Each tag becomes an element node, each piece of text a text node. The form, the buttons and the whitespace between tags are left out here.

Finding elements

querySelector() is a method of document (and of every element) that takes a CSS selector and returns the first matching element, or null when nothing matches. Its sibling querySelectorAll() returns all matches as a NodeList, which you can loop over with for...of or forEach(). Because they use CSS selectors, you can find elements by tag ("h1"), id ("#status"), class (".close"), attribute ("[data-id]") or position ("#tickets li"). This runs in a browser:

const heading = document.querySelector("h1");            // first <h1>
const status = document.querySelector("#status");         // by id
const firstTicket = document.querySelector("#tickets li"); // first li inside #tickets
const buttons = document.querySelectorAll("button.close"); // all matches

console.log(heading.textContent);
console.log(firstTicket.dataset.id);
console.log(buttons.length);
console.log(document.querySelector(".no-such-thing"));
Open outages
INC-30412
2
null

Called on an element instead of document, these methods search only inside that element. The opposite direction is element.closest(selector), which walks up from an element to the nearest ancestor (or the element itself) that matches. You will also see the older document.getElementById("status"), which does the same as querySelector("#status").

Reading and changing elements

Once you have an element, its properties and methods let you read and change it. The most common ones:

const status = document.querySelector("#status");

status.textContent = "1 open ticket";           // replace the text
status.classList.add("warning");                // add a CSS class
status.setAttribute("aria-live", "polite");     // set any attribute
status.hidden = false;                          // many attributes are properties too

const ticket = document.querySelector("#tickets li");
console.log(ticket.dataset.id);                 // reads data-id
ticket.dataset.priority = "high";               // writes data-priority="high"
  • classList has add(), remove(), toggle() and contains(). Changing classes and leaving the looks to the style sheet keeps design and behavior apart.
  • dataset gives access to custom data-* attributes, a convenient place to keep an id such as a ticket number on an element.
  • hidden = true hides an element; remove() takes it out of the tree.

textContent versus innerHTML

Two properties replace an element's content, and the difference between them matters for security.

textContent is the plain text of an element and all its descendants. Reading it returns the text without any tags; assigning to it replaces all children with a single text node. Whatever you assign is shown literally: a < in the string appears as a < on the page and is never treated as markup.

innerHTML is the content of an element written as an HTML string. Reading it gives you markup; assigning to it makes the browser parse the string as HTML and build new elements from it.

const status = document.querySelector("#status");
const summary = "Billing <b>down</b>"; // imagine this came from a form field

status.textContent = summary; // shows: Billing <b>down</b>   (plain text)
status.innerHTML = summary;   // shows: Billing down, "down" in bold (parsed as HTML)

Creating elements safely

To add new content, build the elements yourself: document.createElement(tagName) makes a new, detached element; you set its text and attributes; then you attach it to the tree with append(), prepend(), before() or after(). replaceChildren() swaps all children at once. This builds the ticket list from data, in a browser:

const tickets = [
  { id: "INC-30412", summary: "Portal login slow" },
  { id: "INC-30415", summary: "Meter API timeouts" },
  { id: "INC-30419", summary: "Invoice PDF <missing>" },
];

function ticketItem(ticket) {
  const item = document.createElement("li");
  item.dataset.id = ticket.id;
  item.textContent = `${ticket.id} ${ticket.summary} `; // text, never parsed as HTML

  const button = document.createElement("button");
  button.className = "close";
  button.textContent = "Close";
  item.append(button);
  return item;
}

const list = document.querySelector("#tickets");
list.replaceChildren(...tickets.map(ticketItem)); // swap in the new items at once

The third summary contains <missing>. Because it goes into textContent, the page shows those characters as text instead of trying to create a <missing> element. This approach is safe with any data, and it is easy to read.

Events and event listeners

An event is a signal that something happened: a click, a key press, text typed into a field, a form being submitted, the page finishing loading. An event listener is a function you register on an element (or any other event target) with addEventListener(type, listener); the browser calls it every time an event of that type reaches that target, and passes it an event object with the details.

const button = document.querySelector("#refresh");

function refresh(event) {
  console.log(event.type, "on", event.target.id);
}

button.addEventListener("click", refresh);
// later, when it is no longer needed:
button.removeEventListener("click", refresh);

removeEventListener() needs the very same function, which is why the listener above has a name. Listeners are callback functions. Events from the user are queued and handled by the event loop, one at a time, never in the middle of other code; dispatchEvent(), used below, calls the listeners immediately instead. Events you will use often:

Event Fires when
click an element is clicked or tapped, or a focused button is activated with the keyboard
input the value of a field changes, on every keystroke
change a field's value is committed (for example when it loses focus, or a checkbox is toggled)
submit a form is submitted
keydown a key is pressed (event.key tells which)
DOMContentLoaded the HTML has been parsed and the DOM is ready

The event machinery is not limited to the page. Every DOM element is an EventTarget, and Node.js has the same class, so you can try the listener API without a browser. dispatchEvent() plays the role of the user here:

// Node.js has the same EventTarget and Event classes as the browser,
// without a page. Every DOM element is an EventTarget too.
const meter = new EventTarget();

function logReading(event) {
  console.log(`${event.type}: ${event.detail.kwh} kWh`);
}

meter.addEventListener("reading", logReading);
meter.addEventListener("reading", () => console.log("second listener"), {
  once: true,
});

meter.dispatchEvent(new CustomEvent("reading", { detail: { kwh: 412 } }));
meter.dispatchEvent(new CustomEvent("reading", { detail: { kwh: 398 } }));

meter.removeEventListener("reading", logReading);
meter.dispatchEvent(new CustomEvent("reading", { detail: { kwh: 455 } }));
console.log("done");
reading: 412 kWh
second listener
reading: 398 kWh
done

Listeners run in the order they were added. The option { once: true } removes a listener after its first call, and after removeEventListener() the third reading reaches nobody. CustomEvent carries your own data in event.detail.

Event bubbling

A click on a button is also a click on the list item around it, on the list, on the body and on the document. Event bubbling is how the DOM handles this: after an event has been delivered to the element where it happened, the target, it travels up through every ancestor, and the listeners for that event on each ancestor run in turn. Most events bubble, including click, input, submit and keydown; a few, such as focus and blur, do not.

Event propagation: during the rarely used capturing phase the event travels down from document to the target button; then, during bubbling, it travels back up from the button through li, ul#tickets and body to document. document body ul#tickets li button target 1. capturing (rarely used) 2. bubbling listeners run from the target up to document
An event first travels down to its target, then bubbles back up through every ancestor. Listeners added normally run during the way up.

With listeners on the first close button, the list and the document, one click on that button prints three lines, from the inside out:

const list = document.querySelector("#tickets");
const firstButton = list.querySelector("button.close");

firstButton.addEventListener("click", () => console.log("1. button"));
list.addEventListener("click", () => console.log("2. ul#tickets"));
document.addEventListener("click", () => console.log("3. document"));
1. button
2. ul#tickets
3. document

Two properties of the event object tell you where you are. event.target is the element where the event started (the button, in all three listeners). event.currentTarget is the element whose listener is running now. A listener can call event.stopPropagation() to stop the event from bubbling further, but use it sparingly: other code, such as a menu that closes on any outside click, may rely on seeing the event.

Event delegation

Bubbling makes a useful pattern possible. Event delegation means attaching one listener to a common ancestor instead of one listener to each child, and using event.target (usually with closest()) to find out which child the event came from. For the ticket list, one listener on the <ul> handles every close button, including buttons for tickets that are added later:

const list = document.querySelector("#tickets");

list.addEventListener("click", (event) => {
  const button = event.target.closest("button.close");
  if (!button) return; // the click was somewhere else in the list

  const item = button.closest("li");
  console.log("Closing", item.dataset.id);
  item.remove();
});
Closing INC-30415

closest() matters here: if the button contained an icon, a click on the icon would make the icon the target, and event.target.matches("button.close") would be false. Walking up with closest() finds the button either way. Delegation saves memory with long lists and, more importantly, works for elements that did not exist when the listener was added.

Forms and default actions

Many events come with a default action that the browser performs after the listeners have run: a link navigates, and a form submission sends the data and loads a new page. (A checkbox is a special case: it toggles first, and preventDefault() undoes the toggle.) preventDefault() is the event method that cancels that default action, so your script can handle the event itself. The classic case is a form that should add a ticket without reloading the page:

const form = document.querySelector("#new-ticket");
const list = document.querySelector("#tickets");

form.addEventListener("submit", (event) => {
  event.preventDefault(); // stay on this page: no reload, no navigation

  const summary = form.elements.summary.value.trim();
  if (summary === "") return;

  const item = document.createElement("li");
  item.textContent = summary;
  list.append(item);
  form.reset();
});

Listen for submit on the form rather than click on its button: submit also fires when the user presses Enter in a field. form.elements gives access to the fields by their name. To send the form to a server without a page load, combine this with fetch() and FormData. For live feedback while someone types, listen for input:

const input = document.querySelector("#new-ticket input[name=summary]");
const counter = document.querySelector("#summary-length");

input.addEventListener("input", () => {
  counter.textContent = `${input.value.length} / 80 characters`;
});

Only events created as cancelable can be prevented; preventDefault() on others does nothing. You can see the mechanics with Node.js's EventTarget: dispatchEvent() returns false when a listener canceled the event, which is how the browser decides to skip the default action:

const form = new EventTarget();

form.addEventListener("submit", (event) => {
  console.log("submit handler runs, cancelable:", event.cancelable);
  event.preventDefault(); // "don't do your usual thing"
});

const event = new Event("submit", { cancelable: true });
const notCanceled = form.dispatchEvent(event);

console.log("defaultPrevented:", event.defaultPrevented);
console.log("default action may run:", notCanceled);
submit handler runs, cancelable: true
defaultPrevented: true
default action may run: false