The previous lesson ended with a semantic index.html and with the feeling that reaching an element by hopping from firstElementChild to nextElementSibling is awkward and fragile. It is. The real way of working with the DOM consists of describing what you are looking for with a CSS selector and letting the browser find it for you, and then changing it: its text, its attributes, its classes. In this lesson you will learn both halves of that job, along with the judgment calls that separate professional code from code that works by accident: why textContent is the default option and innerHTML a security risk, why an attribute and a property with the same name may not hold the same value, why changing a class is always better than touching style, and why reading the width of an element can be surprisingly expensive. By the end, the tasks on your page will show the real status and priority coming out of the Board.

Contents

  1. CSS selectors as a common language
  2. The classic methods: getElementById and friends
  3. querySelector and querySelectorAll
  4. Comparison table of the selection methods
  5. Searching inside an element: the scope of the search
  6. closest() and matches()
  7. From NodeList to array
  8. textContent, innerText and innerHTML
  9. Attributes versus properties
  10. dataset and the data-* attributes
  11. Classes with classList
  12. Inline styles with style (and why hardly ever)
  13. Reading geometry: getBoundingClientRect and offsetWidth
  14. Nómada Tasks: painting status and priority
  15. Common Mistakes and Tips
  16. Exercises
  17. Conclusion

  1. CSS selectors as a common language

A CSS selector is an expression that describes a set of elements. You know them from the CSS you wrote in the previous lesson; what matters now is that the same language works for searching from JavaScript. A quick review of the ones you will use most:

Selector Selects Example in Nómada Tasks
p Every element with that tag li
.class Those that have that class .task--high
#id The one with that id #task-list
[attr] Those that have that attribute [data-id]
[attr="value"] Those with that attribute set to that exact value [data-id="6"]
[attr^="v"] Whose attribute starts with [href^="https"]
a, b Those matching a or b .task--high, .task--overdue
a b The bs that are descendants of a .board li
a > b The bs that are direct children of a #task-list > li
a + b The b immediately after an a h2 + p
a:first-child The one that is a first child li:first-child
a:not(sel) The as that do not match the selector li:not(.task--done)
a:has(b) The as that contain a b li:has(button[disabled])

The fact that selectors compose is what makes them powerful. #task-list > li.task--high:not(.task--done) describes, in one line, "the direct children of the list that are high-priority tasks and are not done". Writing that by navigating the tree by hand would be ten lines.

  1. The classic methods: getElementById and friends

Before selectors existed in JavaScript there were three methods, and they are still in use:

// By id. Returns the element or null. Only exists on 'document'.
const list = document.getElementById('task-list');   // no '#'

// By class. Returns a LIVE HTMLCollection (it may be empty).
const highOnes = document.getElementsByClassName('task--high');   // no '.'

// By tag. Also a live HTMLCollection.
const items = document.getElementsByTagName('li');

Three details to keep in mind:

  • getElementById takes no # and the other two take no .: they receive names, not selectors. Getting this wrong is a classic mistake that returns null with no explanation.
  • The two getElements… return live collections, with everything that implies (lesson 06-01, section 6): if you add an <li> with the class, it shows up in the collection by itself; if you loop with a classic for while adding elements, you get an infinite loop.
  • If there are no matches, getElementById returns null and the other two return an empty collection, not null. They are two different ways of saying "nothing", and confusing them causes different errors.

getElementById is still perfectly valid and is the fastest of them all, because the browser keeps an internal index of ids. The other two have been practically displaced by querySelectorAll.

  1. querySelector and querySelectorAll

These two methods accept any CSS selector and are the ones you will use 90 % of the time.

// The FIRST matching element, or null if there is none.
const list = document.querySelector('#task-list');
const firstHigh = document.querySelector('.task--high');
const carpentry = document.querySelector('[data-id="6"]');

// ALL the matches, as a STATIC NodeList (possibly empty).
const tasks = document.querySelectorAll('#task-list > li');
console.log(tasks.length);   // 3

Four things worth internalizing:

They return null or an empty list, and you have to account for it. This is the most frequent TypeError in the whole module:

const button = document.querySelector('#button-that-does-not-exist');
console.log(button);          // null
// button.textContent = 'Hello';
// ✗ TypeError: Cannot set properties of null (setting 'textContent')

The correct guard uses what you already know from 01-06 and from Module 2:

const button = document.querySelector('#save');
if (button === null) {
  console.warn('The save button was not found.');
} else {
  button.disabled = true;
}

// Or with optional chaining, when the absence is acceptable:
document.querySelector('#save')?.setAttribute('disabled', '');

querySelectorAll returns a static NodeList. It is a snapshot of the moment: if you add tasks to the DOM afterwards, that list does not grow. In practice that is an advantage, because it removes the surprises of live collections.

The NodeList does have forEach, even though it still has no map or filter:

document.querySelectorAll('.task').forEach((li) => {
  console.log(li.dataset.id);
});

The selector must be valid, or the method throws an exception instead of returning null:

// document.querySelector('##bad');
// ✗ SyntaxError: Failed to execute 'querySelector': '##bad' is not a valid selector.

Watch out for one specific case: ids that start with a number are valid in HTML but break the selector (#1 is not a legal selector). If you generate ids from data, prefix them with a letter (task-1) or use [data-id="1"], which does not have that problem. And for general cases there is CSS.escape(value).

  1. Comparison table of the selection methods

Method Takes Returns Live? Available on elements? When to use it
getElementById(id) An id without # Element or null No, only on document The fastest; perfect for the root container
getElementsByClassName(c) A class name HTMLCollection Yes Yes Hardly ever; legacy
getElementsByTagName(t) A tag name HTMLCollection Yes Yes Hardly ever; legacy
querySelector(sel) CSS selector Element or null Yes Default option for one element
querySelectorAll(sel) CSS selector Static NodeList No Yes Default option for several

About performance: getElementById is faster than querySelector('#id') because it does not have to parse the selector. The difference is a matter of nanoseconds and does not matter except inside a loop that runs thousands of times. The sensible rule is to use querySelector/querySelectorAll for consistency, store the result in a constant and not search for the same thing twice. In 09-04 you will see how to measure this properly; until then, favor readability.

  1. Searching inside an element: the scope of the search

querySelector and querySelectorAll are not exclusive to document: every element has them, and then they search only among its descendants. This is fundamental for writing components that do not interfere with each other.

const list = document.querySelector('#task-list');
const li = list.querySelector('[data-id="2"]');

// Searches ONLY inside that <li>:
const title = li.querySelector('.task__title');
console.log(title.textContent);   // 'Signage for the screen-printing workshop'

Comparing the two scopes makes the difference obvious:

console.log(document.querySelectorAll('.task__title').length); // 3 · the whole page
console.log(li.querySelectorAll('.task__title').length);       // 1 · only that <li>

A detail that confuses a lot of people: the selector is evaluated against the whole document, and then filtered by "must be a descendant of the element". That is why this gives unexpected results:

// Intuition: "the li children of the list". Reality: it works, but...
list.querySelectorAll('#task-list > li');   // ✓ 3 elements
list.querySelectorAll('> li');              // ✗ SyntaxError: not a valid selector

A selector cannot start with a combinator. If you want only the direct children starting from the element itself, use :scope:

list.querySelectorAll(':scope > li');   // ✓ 3 elements, without repeating the id

In practice, for direct children list.children is usually enough, and you already know it and it is faster.

  1. closest() and matches()

Two small methods with an enormous impact on the code that follows.

element.closest(selector) goes up the tree —starting with the element itself— until it finds the first ancestor that matches the selector, and returns null if it finds none. It is the robust version of chaining parentElement:

const title = document.querySelector('.task__title');

console.log(title.closest('li'));            // <li class="task" data-id="1">…
console.log(title.closest('.board').id);     // ''  ← the section has no id, but it is the one
console.log(title.closest('[data-id]').dataset.id); // '1'
console.log(title.closest('table'));         // null ← there is no table above it

Compare with title.parentElement.parentElement.parentElement: the closest version survives any <div> somebody adds for layout reasons. Keep this method close: in 06-04 it will be the centerpiece of event delegation.

element.matches(selector) searches for nothing: it asks whether the element matches a selector, and returns a boolean.

const li = document.querySelector('[data-id="1"]');

console.log(li.matches('li'));              // true
console.log(li.matches('.task'));           // true
console.log(li.matches('.task--done'));     // false
console.log(li.matches('#task-list > li')); // true

It is the equivalent of classList.contains but with the full power of selectors, and it is ideal as the predicate of a filter:

const open = [...document.querySelectorAll('.task')]
  .filter((li) => !li.matches('.task--done'));

  1. From NodeList to array

A brief reminder from 06-01, because you will need it in every lesson: querySelectorAll returns a NodeList, not an array. It has length, brackets, forEach and is iterable, but no map, filter, reduce, sort or find.

const nodes = document.querySelectorAll('.task');

// ✗ nodes.map(...)   → TypeError
const ids = [...nodes].map((li) => Number(li.dataset.id));
console.log(ids);                    // [1, 2, 6]

// Array.from with a transform function, in a single pass:
const ids2 = Array.from(nodes, (li) => Number(li.dataset.id));

// And with what you know from 04-05, any aggregation:
const byAssignee = Object.groupBy(
  [...document.querySelectorAll('.task')],
  (li) => li.dataset.assignee
);

  1. textContent, innerText and innerHTML

Three properties for reading and writing the content of an element, and one difference between them that matters enormously.

<li class="task" data-id="1">
  <span class="task__title">Redesign the <b>multipurpose</b> room</span>
  <span class="task__meta" hidden>Iván · 12 h</span>
</li>
const li = document.querySelector('[data-id="1"]');

console.log(li.textContent);
// '\n  Redesign the multipurpose room\n  Iván · 12 h\n'   ← ALL the text, hidden included

console.log(li.innerText);
// 'Redesign the multipurpose room'                        ← only the VISIBLE text, normalized

console.log(li.innerHTML);
// '\n  <span class="task__title">Redesign the <b>multipurpose</b> room</span>…'
Property What it reads What it writes Read cost Safe to write?
textContent All the text, including hidden text and whitespace as it is Plain text; tags are shown literally Low Yes
innerText Only the visible text, with the whitespace already normalized Plain text (respects line breaks) High: forces a layout recalculation Yes
innerHTML The inner HTML markup Parses HTML: creates real elements Medium No

The practical conclusion is twofold.

To write text, always use textContent. It is faster and, above all, safe:

const title = li.querySelector('.task__title');
title.textContent = 'Redesign the multipurpose room <urgent>';
// On screen you read it literally:  Redesign the multipurpose room <urgent>

innerHTML parses whatever you give it, and that is where the danger lies. If the text comes from a user —the 06-07 form, a server, the URL—, whoever writes it can inject markup and code. That is called XSS (Cross-Site Scripting):

// Imagine Iván writes this as the title of a task:
const maliciousTitle = '<img src=x onerror="alert(\'Your data is mine\')">';

li.innerHTML = maliciousTitle;
// ✗ The browser creates an image that fails, runs the onerror and executes someone else's code
//   in the context of your page: it can read cookies, send data, change the interface.

li.textContent = maliciousTitle;
// ✓ The literal text '<img src=x onerror="…">' appears on screen. Nothing runs.

One technical nuance that is sometimes misread: innerHTML = '<script>…</script>' does not run that <script>, but that saves nothing, because attributes like onerror, onload or onmouseover do run, and the example above is the demonstration.

The rule you should follow for the rest of the course:

textContent by default. innerHTML only with markup you wrote yourself, literal and with no external data interpolated into it. If you need to insert structure, create it with createElement (lesson 06-05) or escape the text (lesson 06-06).

There is a third reason, less serious but very real, to avoid innerHTML: reassigning it destroys and rebuilds all the inner nodes. Any event handlers you had attached to them disappear, focus is lost and any browser state (a container's scroll position, the selected text) goes with them. You will see this in detail in 06-05 and 06-06.

And about innerText: reading it forces the browser to work out what is visible, which means recalculating the page layout. It is measurable in loops. Use it only when you genuinely need "what the user sees"; for everything else, textContent.

  1. Attributes versus properties

This is one of those topics that look like a subtlety and then explain an entire bug.

  • An attribute is what you write in the HTML: <input value="8" id="hours">. It lives in the markup.
  • A property is a field of the DOM object: input.value. It lives in memory.

When parsing the HTML, the browser creates the property from the attribute. But from that point on they can diverge, and in fact they do.

const input = document.querySelector('#hours');   // <input id="hours" value="8">

console.log(input.getAttribute('value'));   // '8'
console.log(input.value);                   // '8'

// The user types 12 into the field…
console.log(input.getAttribute('value'));   // '8'   ← the attribute does NOT change: it is the initial value
console.log(input.value);                   // '12'  ← the property is the CURRENT value

The correct reading is: the attribute holds the initial value (what was in the HTML); the property holds the current value. To read what the user has typed, always input.value.

The attribute methods:

li.getAttribute('data-id');        // '1'  ← always returns a string or null
li.setAttribute('aria-busy', 'true');
li.hasAttribute('hidden');         // true / false
li.removeAttribute('hidden');
console.log(li.attributes);        // NamedNodeMap with all the attributes

And the table of divergences that has to be memorized:

Case Attribute Property Note
value of an input Initial value (string) Current value (string) Use the property
checked of a checkbox Presence = checked by default Boolean of the current state Use the property
disabled, hidden, required Boolean attributes: being there is enough Boolean setAttribute('disabled','') = disabled
class getAttribute('class') className (string) and classList (object) Use classList
for of a <label> for htmlFor for is a reserved word
href of an <a> What was written: '/tasks' Absolute URL: 'https://…/tasks' Depending on what you need
data-id '1' (string) dataset.id'1' (string) Use dataset

The case of boolean attributes deserves a warning, because it is a very common mistake:

const button = document.querySelector('#mark-done');

button.setAttribute('disabled', 'false');   // ✗ The button ends up DISABLED!
// In HTML, a boolean attribute acts through its mere presence. The value is irrelevant.

button.disabled = false;                    // ✓ Correct: the property is a real boolean
button.removeAttribute('disabled');         // ✓ Also correct

General rule: use the properties. Fall back to getAttribute/setAttribute only for attributes that have no equivalent property —the aria-* ones, the data-* ones when you are not using dataset, or custom attributes.

  1. dataset and the data-* attributes

HTML lets you invent attributes as long as they start with data-. They are valid, they interfere with nothing and they exist for exactly this: storing your application's data on the element that represents it.

<li class="task" data-id="6" data-assignee="Iván" data-priority="high" data-estimated-hours="5">

They are read and written through the dataset property, which exposes each data-* as a key in camelCase:

const li = document.querySelector('[data-id="6"]');

console.log(li.dataset.id);              // '6'
console.log(li.dataset.assignee);        // 'Iván'
console.log(li.dataset.estimatedHours);  // '5'   ← data-estimated-hours → estimatedHours
console.log(li.dataset);                 // DOMStringMap { id: '6', assignee: 'Iván', … }

// Writing creates or updates the attribute in the HTML:
li.dataset.status = 'in-progress';       // adds data-status="in-progress"
delete li.dataset.status;                // removes it

The name conversion works in both directions: data-due-datedataset.dueDate. And there is a trap that costs hours if you do not know it:

console.log(typeof li.dataset.estimatedHours);      // 'string'  ← ALWAYS a string!
console.log(li.dataset.estimatedHours + 1);         // '51'      ← concatenation, not addition
console.log(Number(li.dataset.estimatedHours) + 1); // 6         ✓

Everything that comes out of the DOM is text. It is exactly the lesson of 01-07, and it will come back with forms in 06-07. Always convert explicitly, with Number().

The use we make of it here is the key piece of the whole module: data-id is the bridge between the screen and the model.

import { Board } from './model/board.js';
import { createBacklog } from './data/backlog.js';

const board = new Board('Taller Nómada', createBacklog());

const li = document.querySelector('[data-id="6"]');
const task = board.findById(Number(li.dataset.id));

console.log(task.title);        // 'Carpentry workshop quote'
console.log(task.isOverdue());  // true

That Number(li.dataset.id) is the boundary conversion: the page speaks in strings, the model in numbers.

What to store and what not to. data-id, yes: it is a stable, minimal identifier. Duplicating the whole object on the element (data-title, data-tags, data-reviewer…) is a bad idea, because you would have two copies of the truth that can drift apart. The model is the single source of truth; the DOM stores only the key to get back to it. It is the same principle you will apply in 06-06 when reconciling the list.

  1. Classes with classList

classList is an object specialized in manipulating an element's class list, and its methods are exactly the ones you need:

const li = document.querySelector('[data-id="6"]');

li.classList.add('task--high');                     // adds (if already there, does nothing)
li.classList.add('task--overdue', 'highlighted');   // several at once
li.classList.remove('highlighted');                 // removes (if not there, no failure)
li.classList.toggle('collapsed');                   // adds/removes; returns true if it ended up on
li.classList.toggle('task--done', task.status === 'done');  // ← with a second argument: forces it
li.classList.replace('task--medium', 'task--high'); // replaces; returns true if there was a change
console.log(li.classList.contains('task--high'));   // true
console.log([...li.classList]);                     // ['task', 'task--high', 'task--overdue']

The toggle with a second argument is especially valuable, because it turns four lines into one:

// Before
if (task.status === 'done') li.classList.add('task--done');
else li.classList.remove('task--done');

// After
li.classList.toggle('task--done', task.status === 'done');

The old alternative is className, which is the whole string, and it is dangerous:

li.className = 'task--done';
// ✗ It has WIPED OUT 'task' and 'task--high'. The element loses all its base styling.

li.className += ' task--done';
// ✗ Works until you forget the leading space and create 'task--hightask--done'.
//   And it adds duplicates if it runs twice.

classList has none of those problems: it is idempotent, it does not duplicate and it does not destroy what you did not ask it to touch. Always use classList.

  1. Inline styles with style (and why hardly ever)

The style property gives access to the element's inline styles —the ones in the style="…" attribute—, with the CSS names in camelCase:

li.style.backgroundColor = '#fef3c7';   // background-color
li.style.borderLeftColor = 'crimson';   // border-left-color
li.style.display = 'none';
li.style.removeProperty('display');
li.style.setProperty('--color-high', '#991b1b');   // CSS variables, with setProperty

An important warning: style only reads what is inline, not what comes from the stylesheet.

// In styles.css: .task { padding: 0.6rem 0.8rem; }
console.log(li.style.padding);                            // ''  ← empty, it is not inline
console.log(getComputedStyle(li).padding);                // '9.6px 12.8px' ← the real value

getComputedStyle(element) returns the final styles after applying every rule. It is read-only and, as we will see in the next section, it forces the browser to compute the layout, so do not call it inside a loop.

Why classes are preferred. Changing style from JavaScript splits the presentation across two files: half in the CSS and half hidden in strings inside the JavaScript. When somebody wants to change the red of high-priority tasks, they will look in the CSS and not find it. On top of that, inline styles have extremely high specificity and are very hard to override.

Approach Where the design lives Readability Reusable When to use it
classList.add('task--high') In the CSS High Yes Almost always
style.borderLeftColor = '…' Split Low No Values computed at runtime

The legitimate exception is values that are only known at runtime: the width of a progress bar, the position of a dragged element, a color that comes from data. Even there, the elegant approach is to use a CSS variable:

// The workload bar for one assignee: 25 h out of 45 open
const bar = document.querySelector('#workload-ivan');
bar.style.setProperty('--percentage', `${(25 / 45) * 100}%`);
.bar::before { width: var(--percentage, 0%); }

That way the how it looks keeps living in the CSS and the JavaScript only contributes the data.

  1. Reading geometry: getBoundingClientRect and offsetWidth

Sometimes you need to know where an element is or how big it is. The DOM offers two families:

const li = document.querySelector('[data-id="1"]');

const rect = li.getBoundingClientRect();
console.log(rect);
// DOMRect { x: 24, y: 210, width: 800, height: 41.6, top: 210, right: 824, bottom: 251.6, left: 24 }

console.log(li.offsetWidth, li.offsetHeight);   // 800 42  ← rounded integers, border included
console.log(li.clientWidth);                    // 798      ← without border or scrollbar
console.log(li.scrollHeight);                   // 42       ← total height of the content

Differences worth being clear about:

Property Includes Type Relative to
getBoundingClientRect() Border, and any CSS transform Decimals The visible window (viewport)
offsetWidth / offsetHeight Content + padding + border Integers The element itself
clientWidth / clientHeight Content + padding Integers The element itself
scrollWidth / scrollHeight All the content, even if it overflows Integers The element itself

And here comes the important warning. These readings are synchronous and exact: the browser is obliged to give you the correct measurement right now. If you have just modified the DOM and there are pending layout changes to compute, the browser has to stop everything and recalculate the layout (what is called a reflow or layout) before answering you. Doing it once is irrelevant; doing it inside a loop, alternating writes and reads, is catastrophic:

// ✗ Toxic pattern: writing and reading alternately forces one reflow per pass
for (const li of document.querySelectorAll('.task')) {
  li.classList.add('highlighted');          // writes: invalidates the layout
  console.log(li.offsetHeight);             // reads: forces it to be recomputed NOW
}

// ✓ Separate the phases: first all the reads, then all the writes
const items = [...document.querySelectorAll('.task')];
const heights = items.map((li) => li.offsetHeight);     // all the reads
items.forEach((li) => li.classList.add('highlighted')); // all the writes

This pattern is called layout thrashing and has a whole lesson dedicated to it: Efficient DOM Manipulation. For now, hold on to the rule: batch reads, batch writes, and do not interleave them.

  1. Nómada Tasks: painting status and priority

You now have all the pieces. We are going to make the three hand-written <li>s in index.html show the real data coming out of the Board: the priority class, the strikethrough if they are done, the overdue warning and the correct meta line. It is the first code in the js/view/ folder.

First we extend the HTML so each <li> has its final structure and its data-id:

<ul id="task-list" class="task-list">
  <li class="task" data-id="1">
    <span class="task__title"></span>
    <span class="task__meta"></span>
  </li>
  <li class="task" data-id="3">
    <span class="task__title"></span>
    <span class="task__meta"></span>
  </li>
  <li class="task" data-id="6">
    <span class="task__title"></span>
    <span class="task__meta"></span>
  </li>
</ul>

And now the view module. Notice that nothing from the DOM is imported into the model: it is the view that knows about both things.

// js/view/paint.js
import { TODAY } from '../util/dates.js';
import { statusBadge } from '../util/format.js';

/** Returns the class suffix that corresponds to each priority. */
const PRIORITY_CLASSES = Object.freeze({
  high: 'task--high',
  medium: 'task--medium',
  low: 'task--low'
});

/**
 * Dumps the data of a model Task into its <li> on the page.
 * @param {HTMLElement} li  the <li> with data-id
 * @param {Task} task       the task from the model
 */
export function paintTask(li, task, today = TODAY) {
  // 1 · Text, always with textContent (never innerHTML with data)
  li.querySelector('.task__title').textContent = task.title;
  li.querySelector('.task__meta').textContent =
    `${statusBadge(task.status)} ${task.assignee ?? 'unassigned'} · ` +
    `${task.estimatedHours} h · ${task.status}`;

  // 2 · Priority: we remove the three variants and add the right one
  li.classList.remove(...Object.values(PRIORITY_CLASSES));
  li.classList.add(PRIORITY_CLASSES[task.priority] ?? 'task--medium');

  // 3 · Boolean states: toggle with a second argument
  li.classList.toggle('task--done', task.status === 'done');
  li.classList.toggle('task--overdue', task.isOverdue(today));

  // 4 · Accessibility and supporting data
  li.dataset.status = task.status;
  li.dataset.assignee = task.assignee ?? '';
  li.setAttribute('aria-label',
    `${task.title}, priority ${task.priority}, ${task.status}` +
    (task.isOverdue(today) ? ', overdue' : ''));
}

/** Walks the <li>s present on the page and paints them from the board. */
export function paintList(container, board, today = TODAY) {
  for (const li of container.querySelectorAll('li[data-id]')) {
    const task = board.findById(Number(li.dataset.id));
    if (task === null) {            // guard: the HTML could refer to a non-existent id
      console.warn(`Task ${li.dataset.id} does not exist in the model.`);
      continue;
    }
    paintTask(li, task, today);
  }
}

/** Updates the summary paragraph with the board's numbers. */
export function paintSummary(paragraph, board, today = TODAY) {
  const { total, open, openHours, overdue, effort } = board.summary(today);
  paragraph.textContent =
    `${total} task${total === 1 ? '' : 's'} · ${open} open · ${openHours} h remaining · ` +
    `${overdue} overdue · effort ${effort}`;
}

And the entry point, which stays at four lines:

// js/app.js
import { Board } from './model/board.js';
import { createBacklog } from './data/backlog.js';
import { TODAY } from './util/dates.js';
import { paintList, paintSummary } from './view/paint.js';

const board = new Board('Taller Nómada', createBacklog());

const list = document.querySelector('#task-list');
const summary = document.querySelector('#summary');

paintList(list, board, TODAY);
paintSummary(summary, board, TODAY);

On the screen you now see three tasks with their color stripe according to priority, the carpentry one marked as overdue, and the summary:

6 tasks · 5 open · 45 h remaining · 1 overdue · effort 124

The canonical Taller Nómada numbers, for the first time outside the console. Three design decisions in this code are worth pointing out, because they set the style for the rest of the module:

  • The view reads from the model, never the other way round. paintTask receives a Task and writes to the DOM. Task still does not know a page exists.
  • No innerHTML. Every piece of text goes in through textContent, so a title with < or & cannot break or compromise anything.
  • The class is computed, not accumulated. The remove(...Object.values(...)) before the add guarantees that calling paintTask twice with different priorities leaves the element clean. If you only did add, an <li> would end up with task--high and task--low at the same time.

What is still missing is obvious: the list still has three hand-written <li>s out of a backlog of six, and absolutely nothing happens when you click. Both are solved in the following lessons.

Common Mistakes and Tips

  • getElementById('#list') with a hash. It returns null silently. getElementById takes a bare id; querySelector takes a selector with #. If you keep mixing them up, always use querySelector.
  • Not checking for null. document.querySelector('#does-not-exist').textContent = 'x' throws TypeError: Cannot set properties of null. That error, when it shows up right as the page loads, almost always means the id is misspelled or the element does not exist yet.
  • Using innerHTML with user data. It is the most common security hole in web applications. textContent by default, full stop. If you need structure, create it with nodes (06-05).
  • setAttribute('disabled', 'false'). It leaves the element disabled, because boolean attributes act through their presence. Use the property: button.disabled = false.
  • Forgetting that dataset returns strings. li.dataset.estimatedHours + 1 gives '51'. Convert with Number() at the boundary between the DOM and the model, always.
  • Clobbering className. li.className = 'task--done' erases all the other classes. Use classList.add/remove/toggle.
  • Repeating the same lookup inside a loop. document.querySelector('#list') inside a forEach that runs a thousand times searches a thousand times. Store the result in a constant outside the loop.
  • Tip: name your selection constants deliberately. A widespread convention is to prefix DOM elements: const $list = document.querySelector('#task-list'). It is not mandatory, but it makes it clear at a glance which variables are nodes.
  • Tip: closest() is your friend. Every time you catch yourself writing .parentElement.parentElement, stop and use closest('.whatever'). The resulting code is shorter and does not break when the layout changes.
  • Tip: batch reads and writes. Reading offsetWidth or getBoundingClientRect() right after writing forces a layout recalculation. Measure everything first, then write everything.

Exercises

Exercise 1 · An inventory of the page

Write a function inventory() that returns an object with: the total number of elements with the class task, how many have high priority, how many are overdue and the array of ids (as numbers) of the open tasks. Use querySelectorAll, conversion to an array and the methods from 04-05. Do not access the model: all the information must come from the already painted DOM.

Exercise 2 · Highlight by assignee

Write highlightAssignee(name) that adds the class highlighted to the <li>s whose data-assignee matches name, and removes it from all the rest. It must work when called several times in a row with different names without leaving traces. Add to the CSS the rule needed for highlighted to be noticeable. Solve it in two ways: with classList.toggle and with matches.

Exercise 3 · From attributes to the model, and back

Given the carpentry <li>, write a snippet that: (a) reads its data-id, (b) recovers the Task from the board, (c) marks it as in-progress using the model's method, and (d) repaints only that <li>. Check in the DOM that the class, the data-status and the aria-label have changed, and that the class task--overdue is still there. Explain why it is still there.

Solutions

Exercise 1

function inventory() {
  const items = [...document.querySelectorAll('.task')];

  return {
    total: items.length,
    high: items.filter((li) => li.matches('.task--high')).length,
    overdue: items.filter((li) => li.classList.contains('task--overdue')).length,
    openIds: items
      .filter((li) => li.dataset.status !== 'done')
      .map((li) => Number(li.dataset.id))
  };
}

console.log(inventory());
// { total: 3, high: 3, overdue: 1, openIds: [1, 3, 6] }

Key points: the spread turns the NodeList into an array so you can chain filter and map; matches('.task--high') and classList.contains('task--overdue') are equivalent here, and using both shows that matches accepts full selectors while contains only takes a class name; and the Number(li.dataset.id) is essential, because without it you would get ['1', '3', '6'].

Exercise 2

// Version A · with toggle and its second argument
function highlightAssignee(name) {
  for (const li of document.querySelectorAll('.task')) {
    li.classList.toggle('highlighted', li.dataset.assignee === name);
  }
}

// Version B · with matches and an attribute selector
function highlightAssigneeB(name) {
  document.querySelectorAll('.task')
    .forEach((li) => li.classList.remove('highlighted'));
  document.querySelectorAll(`.task[data-assignee="${name}"]`)
    .forEach((li) => li.classList.add('highlighted'));
}

highlightAssignee('Iván');    // highlights Iván's
highlightAssignee('Lucía');   // removes Iván's and highlights Lucía's
.highlighted { background: #fffbeb; }

Version A is the better one: a single pass, and the toggle with a second argument makes cleaning up beforehand unnecessary. Version B walks the list twice and, on top of that, interpolates a value inside a selector; if the name contained quotes or special characters, the selector could be invalid. If one day you need to build a selector from data, use CSS.escape(name).

Exercise 3

import { paintTask } from './view/paint.js';

const li = document.querySelector('[data-id="6"]');
const task = board.findById(Number(li.dataset.id));   // (a) and (b)

task.changeStatus('in-progress');                     // (c) rule R6 of the model
paintTask(li, task);                                  // (d)

console.log(li.className);              // 'task task--high task--overdue'
console.log(li.dataset.status);         // 'in-progress'
console.log(li.getAttribute('aria-label'));
// 'Carpentry workshop quote, priority high, in-progress, overdue'

task--overdue is still there because rule R10 of the model defines "overdue" as due date in the past and task not finished. The carpentry date is 2026-09-05, earlier than TODAY = '2026-09-20', and 'in-progress' is not 'done': the task is still overdue. The class would only disappear when it moved to 'done'. Notice that the view has not had to know anything about that rule: it has simply asked task.isOverdue(today). The business rule lives in the model, the decoration in the view, and that boundary is what makes this code testable without a browser in Module 8.

Conclusion

You now know how to find anything on the page and change it. To select, the language is CSS selectors and the tools are querySelector (the first match, or null) and querySelectorAll (a static NodeList, possibly empty), with getElementById as the fast option for the root container and the two getElementsBy… relegated to legacy code because of their live collections. You know that both methods also exist on elements, which lets you narrow the search down to a subtree; that :scope solves the direct-children case; and that closest() goes up to the first ancestor matching a selector while matches() answers yes or no about a specific element. That closest() is by far the most important method of the lesson: it is the piece on which event delegation will be built.

To manipulate, you have the judgment rules: textContent as the default way of reading and writing text —cheap and safe—, innerText only when you genuinely need what is visible despite its recalculation cost, and innerHTML under permanent suspicion because of the XSS risk and because it destroys the inner nodes. You tell attributes (the initial value from the markup) from properties (the current value in memory), you know that for value, checked and disabled the properties rule, and that setAttribute('disabled', 'false') disables instead of enabling. You handle dataset for the data-* attributes, with the warning that everything arrives as a string and with the rule of storing only the id in the DOM and leaving the rest of the truth in the model. You use classList with its add, remove, toggle (with the second argument!), contains and replace instead of clobbering className, and you reserve style for values that only exist at runtime, preferably through CSS variables. And you know getBoundingClientRect, offsetWidth and company along with their danger: interleaving reads and writes forces layout recalculations.

In Nómada Tasks that has turned into js/view/paint.js with paintTask, paintList and paintSummary: the project's first real view layer, which takes the Tasks from the Board and dumps them into the page's <li>s with their priority classes, their strikethrough, their overdue warning and their aria-label, showing on screen the same 6 items, 45 h open and effort 124 as always.

But the page is still a poster: it can be seen, and it does not respond. If Marta clicks a task, nothing happens; if Lucía wants to see only her own, there is no way to ask for it. An application is, by definition, something that reacts to what the person using it does, and for that you need the other great pillar of the DOM: events. How a handler is registered, what information the Event object brings, why addEventListener is the only acceptable one of the three ways that exist, and why a <div> with a click leaves out anyone navigating by keyboard, is Handling Events, where Marta will finally be able to mark a task as done with a click.

JavaScript Course: From Beginner to Advanced

Module 1: Introduction to JavaScript

Module 2: Control Structures

Module 3: Functions

Module 4: Objects and Arrays

Module 5: Advanced Objects and Functions

Module 6: The Document Object Model (DOM)

Module 7: Browser APIs and Advanced Topics

Module 8: Testing and Debugging

Module 9: Performance and Optimization

Module 10: JavaScript Frameworks and Libraries

Module 11: Final Project

© Copyright 2026. All rights reserved