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
- CSS selectors as a common language
- The classic methods:
getElementByIdand friends querySelectorandquerySelectorAll- Comparison table of the selection methods
- Searching inside an element: the scope of the search
closest()andmatches()- From
NodeListto array textContent,innerTextandinnerHTML- Attributes versus properties
datasetand thedata-*attributes- Classes with
classList - Inline styles with
style(and why hardly ever) - Reading geometry:
getBoundingClientRectandoffsetWidth - Nómada Tasks: painting status and priority
- Common Mistakes and Tips
- Exercises
- Conclusion
- 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.
- The classic methods:
getElementById and friends
getElementById and friendsBefore 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:
getElementByIdtakes no#and the other two take no.: they receive names, not selectors. Getting this wrong is a classic mistake that returnsnullwith 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 classicforwhile adding elements, you get an infinite loop. - If there are no matches,
getElementByIdreturnsnulland the other two return an empty collection, notnull. 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.
querySelector and querySelectorAll
querySelector and querySelectorAllThese 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); // 3Four 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:
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).
- 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.
- 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 selectorA selector cannot start with a combinator. If you want only the direct children starting from the element itself, use :scope:
In practice, for direct children list.children is usually enough, and you already know it and it is faster.
closest() and matches()
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 itCompare 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')); // trueIt is the equivalent of classList.contains but with the full power of selectors, and it is ideal as the predicate of a filter:
- From
NodeList to array
NodeList to arrayA 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
);
textContent, innerText and innerHTML
textContent, innerText and innerHTMLThree 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:
textContentby default.innerHTMLonly with markup you wrote yourself, literal and with no external data interpolated into it. If you need to insert structure, create it withcreateElement(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.
- 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 valueThe 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 attributesAnd 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 correctGeneral 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.
dataset and the data-* attributes
dataset and the data-* attributesHTML 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.
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 itThe name conversion works in both directions: data-due-date ↔ dataset.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()); // trueThat 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.
- Classes with
classList
classListclassList 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.
- Inline styles with
style (and why hardly ever)
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 setPropertyAn 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 valuegetComputedStyle(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}%`);That way the how it looks keeps living in the CSS and the JavaScript only contributes the data.
- Reading geometry:
getBoundingClientRect and offsetWidth
getBoundingClientRect and offsetWidthSometimes 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 contentDifferences 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 writesThis 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.
- 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:
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.
paintTaskreceives aTaskand writes to the DOM.Taskstill does not know a page exists. - No
innerHTML. Every piece of text goes in throughtextContent, so a title with<or&cannot break or compromise anything. - The class is computed, not accumulated. The
remove(...Object.values(...))before theaddguarantees that callingpaintTasktwice with different priorities leaves the element clean. If you only didadd, an<li>would end up withtask--highandtask--lowat 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 returnsnullsilently.getElementByIdtakes a bare id;querySelectortakes a selector with#. If you keep mixing them up, always usequerySelector.- Not checking for
null.document.querySelector('#does-not-exist').textContent = 'x'throwsTypeError: 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
innerHTMLwith user data. It is the most common security hole in web applications.textContentby 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
datasetreturns strings.li.dataset.estimatedHours + 1gives'51'. Convert withNumber()at the boundary between the DOM and the model, always. - Clobbering
className.li.className = 'task--done'erases all the other classes. UseclassList.add/remove/toggle. - Repeating the same lookup inside a loop.
document.querySelector('#list')inside aforEachthat 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 useclosest('.whatever'). The resulting code is shorter and does not break when the layout changes. - Tip: batch reads and writes. Reading
offsetWidthorgetBoundingClientRect()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'sVersion 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
- What Is JavaScript?
- Setting Up Your Development Environment
- Your First JavaScript Program
- JavaScript Syntax and Basic Concepts
- Variables and Data Types
- Basic Operators
- Type Conversion and Comparisons
- The Course Project: Nómada Tasks
Module 2: Control Structures
- Conditional Statements
- Loops: for, while, do-while
- Switch Statements
- Flow Control: break, continue and Nested Loops
- Error Handling with try-catch
Module 3: Functions
- Defining and Calling Functions
- Function Expressions and Arrow Functions
- Parameters and Return Values
- Scope and Closures
- Hoisting and the Execution Context
- Higher-Order Functions
- Recursion
Module 4: Objects and Arrays
- Introduction to Objects
- Object Methods and the
thisKeyword - Arrays: Basics and Methods
- Iterating over Arrays
- Searching, Sorting and Aggregating Data: find, sort and reduce
- Array Destructuring
- Object Destructuring, Spread and Rest
- JSON and Copying Objects
Module 5: Advanced Objects and Functions
- Prototypes and Inheritance
- Classes and Object-Oriented Programming
- Encapsulation: Getters, Setters and Private Fields
- Modules: Import and Export
- Asynchronous JavaScript: Callbacks
- Promises and Async/Await
- The Event Loop and the Microtask Queue
- Iterators and Generators
Module 6: The Document Object Model (DOM)
- Introduction to the DOM
- Selecting and Manipulating DOM Elements
- Handling Events
- Propagation, Delegation and Custom Events
- Creating and Removing DOM Elements
- Rendering Lists and HTML Templates
- Handling and Validating Forms
Module 7: Browser APIs and Advanced Topics
- Local and Session Storage
- The Fetch API and AJAX
- Robust Requests: Errors, Timeouts and AbortController
- WebSockets
- Service Workers and Progressive Web Apps (PWAs)
- Essential Browser APIs
- Introduction to WebAssembly
Module 8: Testing and Debugging
- Debugging JavaScript
- Code Quality: ESLint, Prettier and Conventions
- Unit Testing with Jest
- Test Doubles: Mocks, Stubs and Spies
- Integration Testing
- End-to-End Testing with Cypress
Module 9: Performance and Optimization
- Measure Before You Optimize: DevTools and Web Vitals
- Optimizing JavaScript Performance
- Memory Management
- Efficient DOM Manipulation
- Lazy Loading and Code Splitting
Module 10: JavaScript Frameworks and Libraries
- Why Frameworks Exist
- Introduction to React
- State Management with Redux
- Vue.js Basics
- Angular Basics
- Choosing the Right Framework
