JavaScript DOM and Events: Select, Update and Respond to Clicks
ProgrammingLesson 3 of 46 min read
What you'll learn
- Select elements with querySelector and change their text, attributes and classes
- Create elements and add them to the page safely
- Attach event listeners and use the event object, including preventDefault
- Use event delegation to handle many similar elements with one listener
On this page
A web page is HTML text in a file, but once the browser has read it, it exists as a live tree of objects called the DOM (Document Object Model). JavaScript does not edit the HTML file; it edits that tree, and the browser redraws whatever changed. That single idea explains most of what follows: you find a node, change a property on it, and the screen updates.
To make this concrete we will build a seat picker for a small studio theatre: a grid of seat buttons, a line showing what the visitor has chosen, and a booking form. The examples in this lesson run in a browser, not in Node, so save the page below as seats.html and open it. The behaviour is described in words after the listing.
The whole page, then we take it apart
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Studio B seats</title>
<style>
#seats { display: grid; grid-template-columns: repeat(4, 3rem); gap: 0.4rem; }
#seats button { padding: 0.5rem 0; }
#seats button.selected { background: #2563eb; color: #fff; }
#seats button:disabled { opacity: 0.4; }
</style>
</head>
<body>
<h1>Studio B: pick your seats</h1>
<div id="seats"></div>
<p id="summary">No seats chosen.</p>
<form id="booking">
<label>Your name <input id="who" required></label>
<button type="submit">Book</button>
</form>
<p id="status" role="status"></p>
<script>
const rows = ["A", "B", "C"];
const taken = new Set(["A2", "B3"]);
const MAX_SEATS = 3;
const chosen = new Set();
const seatsEl = document.querySelector("#seats");
const summaryEl = document.querySelector("#summary");
const statusEl = document.querySelector("#status");
for (const row of rows) {
for (let n = 1; n <= 4; n++) {
const id = row + n;
const btn = document.createElement("button");
btn.type = "button";
btn.textContent = id;
btn.dataset.seat = id;
btn.disabled = taken.has(id);
seatsEl.append(btn);
}
}
function render() {
summaryEl.textContent = chosen.size === 0
? "No seats chosen."
: `Chosen: ${[...chosen].sort().join(", ")}`;
}
// one listener on the parent handles every seat
seatsEl.addEventListener("click", (event) => {
const btn = event.target.closest("button[data-seat]");
if (!btn) return;
const id = btn.dataset.seat;
if (chosen.has(id)) {
chosen.delete(id);
} else if (chosen.size < MAX_SEATS) {
chosen.add(id);
} else {
statusEl.textContent = `You can pick at most ${MAX_SEATS} seats.`;
return;
}
btn.classList.toggle("selected", chosen.has(id));
statusEl.textContent = "";
render();
});
document.querySelector("#booking").addEventListener("submit", (event) => {
event.preventDefault(); // stay on the page
const name = document.querySelector("#who").value.trim();
if (name === "") {
statusEl.textContent = "Please enter your name.";
return;
}
if (chosen.size === 0) {
statusEl.textContent = "Choose at least one seat first.";
return;
}
statusEl.textContent = `Booked ${[...chosen].sort().join(", ")} for ${name}.`;
});
</script>
</body>
</html>What you see in the browser. A heading, then a grid of twelve buttons labelled A1 to C4, with A2 and B3 greyed out and unclickable. Below the grid the line reads "No seats chosen." Clicking A1 turns that button blue and the line changes to "Chosen: A1". Clicking C4 and B2 as well makes it read "Chosen: A1, B2, C4". Clicking A1 again un-highlights it. Trying a fourth seat leaves the grid alone and shows "You can pick at most 3 seats." Submitting the form with no name shows "Please enter your name." and with a name and seats shows "Booked A1, B2, C4 for Mira." without the page reloading.
Finding and changing elements
document.querySelector("#seats") takes a CSS selector and returns the first matching element, or null if there is none. querySelectorAll returns all matches as a NodeList you can loop over with for...of. Once you hold an element you change it through properties: textContent sets plain text, classList.add/remove/toggle changes CSS classes, dataset.seat reads the data-seat attribute, disabled and value mirror the matching HTML attributes, and setAttribute handles anything else. Prefer toggling a class and styling it in CSS over writing styles from JavaScript, as the seat highlight does.
Here is a second, smaller pattern you will write constantly: filtering a list as the visitor types. It needs the input event, which fires on every keystroke, and it hides rows instead of deleting them.
<input id="q" placeholder="Filter stops">
<ul id="stops">
<li>Harbour</li>
<li>Airport</li>
<li>Old Market</li>
</ul>
<script>
const q = document.querySelector("#q");
const items = document.querySelectorAll("#stops li");
q.addEventListener("input", () => {
const term = q.value.trim().toLowerCase();
for (const li of items) {
li.hidden = !li.textContent.toLowerCase().includes(term);
}
});
</script>What you see. Three stops are listed. Typing "ar" leaves only Harbour and Old Market visible ("Market" contains "ar"); typing "port" leaves only Airport; clearing the box shows all three again, because an empty string is included in every name. The hidden property mirrors the HTML hidden attribute, so no CSS is needed.
Events and the event object
element.addEventListener("click", handler) registers a function that the browser calls when the event happens, passing an event object. Two properties are used constantly: event.target, the element that was actually clicked, and event.preventDefault(), which cancels the browser's default action. For a form, the default is to send the data and reload the page, which is rarely what a script-driven form wants.
The picker uses event delegation. Instead of attaching twelve listeners, one is attached to the container. Events bubble up from the clicked button through its ancestors, so the container hears every click and uses event.target.closest("button[data-seat]") to work out which seat it was. The benefits are fewer listeners and, importantly, it keeps working for buttons added to the grid later. Disabled buttons never fire click events at all, which is why the taken seats need no extra check.
| You want to... | Use | Note |
|---|---|---|
| Run code when a button is clicked | addEventListener("click", fn) | Prefer this to inline onclick attributes |
| Stop a form reloading the page | event.preventDefault() in a submit handler | Listen for submit, not the button's click |
| Handle many similar elements | One listener on the parent plus closest() | Works for elements added later |
| React to typing | input event on the field | change fires later, on blur or Enter |
| Run code once the DOM exists | Put the script at the end of body, or use defer | Otherwise querySelector returns null |
Common mistakes and the errors they produce
- Running the script before the elements exist.
TypeError: Cannot read properties of null (reading 'addEventListener')meansquerySelectorfound nothing. Move the script below the HTML or add thedeferattribute to an external script. - Typo in the selector.
#seatinstead of#seatsalso returnsnull, giving the same error. Log the element right after you select it. - Calling the handler instead of passing it.
addEventListener("click", handle())runshandleimmediately and registers its return value. Passhandle, without parentheses. - Listening for the wrong form event. A click listener on the submit button does not run when the user presses Enter in a field. Listen for
submiton the form. - Forgetting `preventDefault`. The page reloads and your message vanishes, so it looks as if nothing happened.
- Building HTML from user input with `innerHTML`. A name such as
<img src=x onerror=...>runs code. UsetextContent. - Expecting `querySelectorAll` to return an array. It returns a
NodeList;.mapis missing, so useArray.from(list).map(...)or[...list].
How this shows up in real projects and interviews
Most production front ends use a framework such as React instead of calling querySelector by hand, but the framework is built on exactly these mechanics: state changes, the DOM is updated, events bubble. Knowing the raw DOM makes framework behaviour (synthetic events, keys, refs) much less magical, and it is the reason React events and forms will feel familiar. In interviews, expect "explain event bubbling and delegation", "what does preventDefault do", and a small task such as toggling a class or filtering a list on input. Structure and styling are covered in HTML fundamentals and CSS fundamentals.
Try it yourself
Extend seats.html with a "Clear" button that deselects every seat and resets the summary line, and a price line that shows the total at 12 per seat (for example "Total: 36"). Keep a single click listener on the seat grid, and make the total update whenever a seat is added or removed.
Show a hint
To clear, loop over seatsEl.querySelectorAll("button.selected"), remove the selected class from each, call chosen.clear() and re-render. Compute the total inside render() as chosen.size * 12.
Key takeaways
- The DOM is a live tree; JavaScript changes properties on its nodes and the browser redraws.
- Use
querySelector,textContent,classListanddataset; avoidinnerHTMLwith user-supplied text. addEventListenerregisters a handler that receives an event object withtargetandpreventDefault().- Event delegation puts one listener on a parent and uses
closest()to identify the clicked child. - Scripts must run after their elements exist, otherwise selectors return
null.
Test yourself
A trick from this lesson's ride: textContent shows it; innerHTML runs it Four more tricks like it wait along the route, each followed by a question.
Frequently asked questions
What is the DOM in JavaScript?
The DOM (Document Object Model) is the browser's in-memory tree of objects representing the page. JavaScript reads and changes those objects through the document object, and the browser updates what is shown on screen. It is created from the HTML but is separate from the HTML file.
What is event delegation in JavaScript?
Event delegation means attaching one listener to a parent element instead of one to each child. Because events bubble up from the clicked element, the parent can inspect event.target to see which child was involved. It uses fewer listeners and automatically covers children added later.
What is the difference between textContent and innerHTML?
textContent sets or reads plain text and never interprets markup. innerHTML parses a string as HTML, which is flexible but dangerous with user input because it can run injected scripts. Use textContent unless you deliberately need to insert trusted HTML.