The DOM and Events
JavaScript in the web page: finding and changing elements in the DOM, creating content safely, responding to clicks and form input with event listeners, and event delegation.
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>
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"
-
classListhasadd(),remove(),toggle()andcontains(). Changing classes and leaving the looks to the style sheet keeps design and behavior apart. -
datasetgives access to customdata-*attributes, a convenient place to keep an id such as a ticket number on an element. -
hidden = truehides 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.
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