Skip to main content
ANVISoftware Solutions
Lesson 12 of 20Beginner16 min

The DOM

By the end of this lesson

Read and change the page from JavaScript.

When the browser reads your HTML it builds a tree of objects in memory. That tree is the DOM, and it is what is actually on screen. Your HTML file was the starting instructions.

The distinction matters because the DOM is live. Change it and the page changes immediately. Your HTML file is not updated and does not need to be — reload and you are back to the starting point.

So "showing the filtered employee list" means finding the right part of that tree and changing it.

Finding elements and reading them
JavaScript
// One element, or null when nothing matches
const searchInput = document.querySelector("#employee-search");

// Every match, as a list
const rows = document.querySelectorAll(".employee-row");

console.log(searchInput.value);    // what the reader has typed
console.log(rows.length);          // how many rows are on the page

// Writing text back
const resultCount = document.querySelector("#result-count");
resultCount.textContent = rows.length + " employees";

// Classes and data attributes
rows[0].classList.add("is-selected");
console.log(rows[0].dataset.employeeId);   // reads data-employee-id
  • querySelector takes a CSS selector — the same selectors from the CSS lessons — and returns the first match.
  • It returns null when there is no match, and reading a property of null throws. Most "cannot read properties of null" errors are a selector that did not match, or a script that ran before the element existed.
  • querySelectorAll returns every match. It is a snapshot, not a live list: add a row afterwards and this list still has the old count.
  • value is how you read what is in a form field. textContent is how you read or write the text of an ordinary element.
  • classList.add, .remove and .toggle change classes without disturbing the others. Setting className replaces all of them, which quietly removes classes you did not know about.
  • dataset exposes data-* attributes, converting the hyphenated name to camelCase. This is the tidy way to attach an id to a row so a click handler knows which record it belongs to.
Building rows safely
JavaScript
function createEmployeeRow(employee) {
  const row = document.createElement("li");
  row.className = "employee-row";
  row.dataset.employeeId = employee.id;

  const name = document.createElement("span");
  name.className = "employee-name";
  name.textContent = employee.name;

  const department = document.createElement("span");
  department.className = "employee-department";
  department.textContent = employee.department;

  row.append(name, department);
  return row;
}

const list = document.querySelector("#employee-list");
list.replaceChildren(...employees.map(createEmployeeRow));
  • createElement makes an element that is not on the page yet. Nothing is visible until it is attached.
  • Setting textContent is safe whatever the value contains. A department called "R&D <Europe>" displays exactly like that instead of being parsed.
  • append takes several children at once, and accepts plain strings as text.
  • replaceChildren clears the list and inserts the new children in a single step. Before it existed this took two operations and an easily forgotten clear.
  • Every row is built in memory first and inserted once. Inserting inside a loop makes the browser recalculate layout repeatedly, which is noticeable on a list of a few hundred rows.
  • This is more lines than the innerHTML version. It is also the version that cannot be turned into an injection by an unusual employee name.

The handful of methods that cover most DOM work:

document.querySelector(selector)
First match, or null. Your default tool for finding one element.
document.querySelectorAll(selector)
All matches, as a static snapshot. Loop it with forEach or a for...of.
element.textContent
Read or write the text. Safe with any value, and what you should reach for by default.
element.innerHTML
Read or write markup. Only ever with values you produced yourself, never with input, API data or stored records.
document.createElement(tag)
Create a detached element, ready to configure before inserting.
parent.append(...nodes)
Add children at the end. replaceChildren swaps the whole set.
element.remove()
Take the element out of the tree, along with its children.
element.classList
add, remove, toggle and contains, without touching other classes.
element.dataset
Read and write data-* attributes. Where to keep a record id that a handler will need.

Summary

  • The DOM is the live tree the browser built from your HTML; changing it changes the page
  • querySelector uses CSS selectors and returns null when nothing matches
  • textContent treats a value as text; innerHTML parses it as markup
  • Never put input, stored records or API data through innerHTML — that is how cross-site scripting happens
  • Build elements with createElement and insert once, and keep state in variables rather than reading it back out of the page

Practice

Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.

Try it yourself

Render a filtered list

You have an array of employees and an input with id employee-search. Write a function that filters the array by name, renders one row per match, and writes the match count into an element with id result-count.

Handle the case where nothing matches — an empty list with no message leaves the reader wondering whether the page is broken.

Show solution

The empty case is a real state, not an edge case. A short message is the difference between "no results" and "something went wrong", and the reader cannot tell those apart from an empty box.

Every value from the data goes in through textContent, so a name containing an ampersand or angle bracket is displayed rather than parsed. That is the habit worth forming: text in, text out.

The count is written as text too. It also helps screen reader users, who cannot see the list shrink — putting the count in a region marked aria-live="polite" in the HTML would have it announced as the results change.

JavaScript
const list = document.querySelector("#employee-list");
const resultCount = document.querySelector("#result-count");

function renderEmployees(employees, term) {
  const needle = term.trim().toLowerCase();

  const matches = employees.filter((employee) =>
    employee.name.toLowerCase().includes(needle)
  );

  resultCount.textContent = matches.length + " of " + employees.length + " employees";

  if (matches.length === 0) {
    const empty = document.createElement("li");
    empty.className = "employee-empty";
    empty.textContent = "No employees match that name.";
    list.replaceChildren(empty);
    return;
  }

  list.replaceChildren(...matches.map(createEmployeeRow));
}

Think about it

Think about it

An expense description is entered by an employee and later shown on an approver's screen with innerHTML. Explain, without writing any attack, why that is a security problem and not only a formatting one.

Show solution

innerHTML parses its value, so the description is no longer data — it becomes part of the page's structure. Markup in the description turns into elements, and markup can carry attributes that execute code.

The code runs in the approver's browser, inside your page's origin, with the approver's session. It can read what is on screen and make requests as that person. So a field one employee controls becomes code running as a different, more privileged user.

The formatting version of the problem — an ampersand rendering oddly — is the same cause with a harmless symptom. Fixing it with textContent fixes both, which is why textContent should be the default rather than the careful choice.

Saved in this browser only.