Courses Learn Blog Contact
Get Started →
Lesson 4

Day 4: The DOM and Events

Selecting, changing and building the page from data, handling clicks and form submissions, and using event delegation so rows added later still work.

Webbo3 Level 2: JavaScript Development 20 min read Free

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.

JSapp.js
// 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.

HTMLindex.html
<!-- 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

JSapp.js
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';
Result in the browser
You writeIt doesUse it for
el.textContent = xReplaces the text, treats it as plain textAlmost always
el.innerHTML = xReplaces the contents and parses it as HTMLOnly with content you built yourself
el.classList.add(c)Adds a classTurning a CSS state on
el.classList.remove(c)Removes a classTurning it off
el.classList.toggle(c)Adds if missing, removes if presentMenus, dark mode, accordions
el.style.color = xSets one inline styleRarely: 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.

JSapp.js
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> &middot; ${c.hours}h
      <em>${c.done ? 'completed' : 'in progress'}</em>
    </li>`)
  .join('');
Result in the browser

.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

JSapp.js
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.target is 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.
EventFires when
clickAnything is clicked or tapped
inputA field changes, on every keystroke
changeA field changes and loses focus, or a select changes
submitA form is submitted, by button or by Enter
keydownA key goes down, useful for Escape and Enter
focus / blurA 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.

JSapp.js
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);
});
Result in the browser

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.

JSapp.js
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.

JSapp.js
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.

  1. Typing text and pressing Add appends it to the list.
  2. Submitting an empty field shows a visible error message and adds nothing.
  3. Every row has a Delete button that removes that row, handled by one delegated listener on the list.
  4. When the list is empty, show an "Nothing here yet" message; hide it as soon as there is a row.
  5. The user's text goes in with textContent, never innerHTML. 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.

Learn this with a real instructor

Free lessons take you a long way on your own. The full programmes add live classes, assignments, Skill Points and a certificate — built for African students.