Webbo3 Level 2 · JavaScript Development · Day 4 of 20
The DOM and Events
The day your JavaScript stops printing to a console and starts changing the page in front of a real person.
By the end of today you can
- Select any element on a page and change its text, its classes or its styles
- Build HTML from an array of data and put it on the page
- Respond to a click, a typed character and a submitted form
- Explain event bubbling, and use it deliberately with one listener instead of fifty
- Validate a form and show the user what is wrong without reloading the page
Everything you have written so far has been invisible. It ran, it printed to a console only you can see, and the page never changed. Today that ends. The DOM is the bridge, and crossing it is the single biggest jump in this course.
1. What the DOM actually is
When the browser loads your HTML, it does not keep the text of your file. It builds a live tree of objects in memory, one object per tag, and renders the page from that tree. That tree is the Document Object Model.
This matters because of what follows: JavaScript does not edit your HTML file. It edits the tree, and the browser re-renders from it immediately. Your file on disk never changes. Refresh the page and every change is gone, because the tree is rebuilt from the file again. That single fact explains most of Week 3.
See the tree for yourself
Open DevTools and look at the Elements tab. That is not your file: it is the live tree. Double-click any text in it and change it. The page updates instantly, your file does not, and a refresh puts it back. That is the whole idea in ten seconds.
2. Selecting elements
Two methods do essentially all of it, and both take a CSS selector, which you already know from last month.
// the FIRST match, or null if nothing matches
const title = document.querySelector('h1');
const firstCard = document.querySelector('.card');
const form = document.querySelector('#signup-form');
// EVERY match, as a list you can loop over
const allCards = document.querySelectorAll('.card');
console.log(allCards.length);
allCards.forEach((card) => console.log(card.textContent));The error that greets everybody on day one
Cannot read properties of null means querySelector found nothing and returned null. Nine times out of ten the script tag is in the <head>, so it ran before the elements existed. Put your <script> at the very end of <body>, or add the defer attribute. That is the fix.
<!-- runs too early: the h1 does not exist yet -->
<head><script src="app.js"></script></head>
<!-- either of these is correct -->
<head><script src="app.js" defer></script></head>
...
<script src="app.js"></script>
</body>3. Changing what is on the page
const heading = document.querySelector('#greeting');
const badge = document.querySelector('#badge');
heading.textContent = 'Welcome back, Chidinma';
badge.textContent = '3 new';
badge.classList.add('is-active');
heading.style.color = '#0a6e6e';| You write | It does | Use it for |
|---|---|---|
el.textContent = x | Replaces the text, treats it as plain text | Almost always |
el.innerHTML = x | Replaces the contents and parses it as HTML | Only with content you built yourself |
el.classList.add(c) | Adds a class | Turning a CSS state on |
el.classList.remove(c) | Removes a class | Turning it off |
el.classList.toggle(c) | Adds if missing, removes if present | Menus, dark mode, accordions |
el.style.color = x | Sets one inline style | Rarely: prefer a class |
textContent or innerHTML: this is a security decision
If the text came from a user and you put it in with innerHTML, they can type a tag and the browser will run it. That is called XSS and you will spend Day 17 on it. The rule from today: user text always goes in with textContent. Reach for innerHTML only for markup you wrote yourself.
4. Building HTML from data
This is the pattern behind every feed, list and dashboard you have ever used: take an array, map it into HTML, and put it on the page in one go.
const courses = [
{ name: 'HTML & CSS', hours: 40, done: true },
{ name: 'JavaScript', hours: 60, done: false },
{ name: 'React', hours: 50, done: false }
];
const list = document.querySelector('#courses');
list.innerHTML = courses
.map((c) => `
<li class="${c.done ? 'done' : ''}">
<strong>${c.name}</strong> · ${c.hours}h
<em>${c.done ? 'completed' : 'in progress'}</em>
</li>`)
.join('');.join('') at the end is not optional. map gives you an array of strings, and assigning an array to innerHTML produces commas between every item, because JavaScript converts the array to text by joining it with commas. Forget join and you get a list with a comma between each row, which is a five-minute confusion the first time and never again.
Why build the whole string then assign once
Assigning to innerHTML inside a loop makes the browser re-parse and re-render on every single pass. Build one string, assign once. With ten items nobody notices; with a thousand the page visibly stutters, and you will meet exactly this on Day 14.
5. Events: reacting to a person
const button = document.querySelector('#save');
button.addEventListener('click', (event) => {
console.log('clicked', event.target.textContent);
});addEventListener takes the name of the event and a function to run when it happens. That function is handed an event object describing what occurred, and two of its properties earn their keep immediately:
event.targetis the element the event actually started on.event.preventDefault()stops the browser doing its own default thing, which for a form means stopping the page reload.
| Event | Fires when |
|---|---|
click | Anything is clicked or tapped |
input | A field changes, on every keystroke |
change | A field changes and loses focus, or a select changes |
submit | A form is submitted, by button or by Enter |
keydown | A key goes down, useful for Escape and Enter |
focus / blur | A field is entered or left |
6. Forms without a page reload
A form's default behaviour is to send itself to a server and reload the page. For an application built in JavaScript, you almost always want to stop that and handle it yourself.
const form = document.querySelector('#signup');
const error = document.querySelector('#error');
form.addEventListener('submit', (event) => {
event.preventDefault(); // stop the reload
const email = form.email.value.trim(); // .trim() removes stray spaces
if (email === '') {
error.textContent = 'Email is required.';
return;
}
if (!email.includes('@')) {
error.textContent = 'That does not look like an email address.';
return;
}
error.textContent = '';
console.log('submitting', email);
});Three things in there are worth copying into every form you ever write. event.preventDefault() first. .trim() on every text value, because a user who typed a trailing space did not mean to. And an early return after each failed check, so the rest of the function only ever runs on good input.
7. Bubbling, and one listener instead of fifty
When you click a button inside a list item inside a list, the event fires on the button, then on the list item, then on the list, then on up to the document. That upward journey is called bubbling, and it is not a quirk to work around: it is a tool.
Because the event passes through the parent, you can listen once on the parent and work out which child was clicked. This is called event delegation, and it is how you handle a list whose contents keep changing.
const list = document.querySelector('#tasks');
// ONE listener, on the parent, forever
list.addEventListener('click', (event) => {
const deleteButton = event.target.closest('.delete');
if (!deleteButton) return; // they clicked something else
const row = deleteButton.closest('li');
row.remove();
});Why this matters more than it looks
Attach a listener to each delete button and you must remember to attach one to every new button you create later. Miss it, and the newest row is the one that does not delete: a bug that only shows up after the user adds something, which is exactly the bug nobody catches before shipping. One listener on the parent has no such problem.
Why `event.target.closest('.delete')` rather than checking the target itself?
Because if the button contains an icon, the click lands on the icon, not the button, and event.target is the icon. closest() walks up from wherever the click landed until it finds a matching ancestor, so it works whatever is inside the button.
8. Creating elements properly
innerHTML is quick. createElement is safe, and it is what you want as soon as user-supplied text is involved.
function taskRow(text) {
const li = document.createElement('li');
li.className = 'task';
const span = document.createElement('span');
span.textContent = text; // safe: text stays text
const button = document.createElement('button');
button.className = 'delete';
button.type = 'button';
button.textContent = 'Delete';
li.append(span, button);
return li;
}
document.querySelector('#tasks').append(taskRow('Finish Day 4 exercise'));Do this now
Build a single page with a text input, an Add button and an empty list. No styling effort required today: behaviour only.
- Typing text and pressing Add appends it to the list.
- Submitting an empty field shows a visible error message and adds nothing.
- Every row has a Delete button that removes that row, handled by one delegated listener on the list.
- When the list is empty, show an "Nothing here yet" message; hide it as soon as there is a row.
- The user's text goes in with
textContent, neverinnerHTML. Test it by adding a task called<b>bold</b>and confirming it appears as those exact characters rather than as bold text.
The test that proves you did the last one right
Type <img src=x onerror=alert(1)> as a task. With textContent you see that text on the page and nothing happens. With innerHTML you get a popup. That is the whole of Day 17 in one keystroke, and it is worth feeling now.
Checkpoint
Q1. Your script says `null` for every element. What is almost certainly wrong?
The script ran before the elements existed. Move the <script> to the end of <body> or add defer.
Q2. `list.innerHTML = items.map(...)` shows commas between every row. Why?
map returns an array, and converting an array to a string joins it with commas. Add .join('').
Q3. When must you use `textContent` rather than `innerHTML`?
Whenever the text came from a user. innerHTML parses what you give it as HTML, so a user can inject tags. textContent always treats it as text.
Q4. Why is one delegated listener better than one listener per button?
Because rows added later still work. A per-button listener only exists on buttons that existed when it ran, so newly created rows do nothing.
Tick before you move on
- ☐ My script runs after the DOM exists and no selector returns null
- ☐ I built rows from an array with map and join
- ☐ Delete works through a single delegated listener on the parent
- ☐ I tested with
<b>bold</b>and it displayed as text, not as markup
Quick recap
The DOM is a live tree in memory, not your file, so refresh undoes everything · querySelector for one, querySelectorAll for many, both take CSS selectors · textContent for user text, innerHTML only for markup you wrote · preventDefault() stops the form reloading the page · Events bubble, so one listener on the parent handles rows that do not exist yet
Tomorrow, Day 5: your first solo project, and the part that matters more than the code: standing up and defending it.