You already know how to manufacture the <li> of a task. What you still do not have is a way of working: right now you paint once at startup and then patch each element by hand inside the click handler. That works with three actions and becomes unmanageable with fifteen, because every action has to remember to update the <li>, the summary, the hours counter, the workload panel and whatever comes next. The alternative is a change of mindset: instead of describing what has to be modified, you write a function that describes how the screen should look given a state, and you call it again every time the state changes. In this lesson you will build that cycle, learn to group the board into columns, to escape data if you use text templates, and to solve the problem that appears as soon as you redraw everything: that focus, scrolling and whatever the user was typing get lost along the way.

Contents

  1. From a single node to the complete list: render(state)
  2. The cycle state → render → event → new state → render
  3. Templates with template literals and escapeHtml
  4. Three ways of producing HTML, compared
  5. <template> for the cards
  6. Grouping the board by status with Object.groupBy
  7. Empty states, counters and summary
  8. The problem of redrawing everything
  9. Partial updating: keys and node reuse
  10. reconcile(list, data)
  11. Sorting and filtering in the view without touching the model
  12. Nómada Tasks: view/board-view.js
  13. Common Mistakes and Tips
  14. Exercises
  15. Conclusion

  1. From a single node to the complete list: render(state)

Compare the two ways of thinking:

// ✗ Imperative: describing the changes, one by one
button.addEventListener('click', () => {
  board.changeStatus(id, 'done');
  li.classList.add('task--done');
  li.querySelector('.task__meta').textContent = '…';
  button.disabled = true;
  summary.textContent = '…';
  hoursCounter.textContent = '…';
  // … and everything somebody adds tomorrow
});

// ✓ Declarative: describing the result, and drawing it again
button.addEventListener('click', () => {
  board.changeStatus(id, 'done');
  render();                     // a single line, always the same one
});

The render function takes the state and produces the screen. Nothing else. It does not know what has changed and does not care: it draws what is there now.

/** View state: the model + the display preferences. */
const state = {
  board,                                    // the Board from the model
  filters: { assignee: null, status: null },
  sort: 'priority',
  today: TODAY
};

function render() {
  const visible = applyFilters(state);
  list.replaceChildren(...visible.map((t) => createCard(t, state.today)));
  paintSummary(summary, state.board, state.today);
}

The advantage is not writing less code: it is that the screen cannot get out of sync with the state. In the imperative version there is always a case where somebody forgets to update one of the five things and the interface shows a lie. In the declarative one, that is impossible by construction.

  1. The cycle state → render → event → new state → render

This is the architecture of almost any modern interface application, and now you are going to write it by hand:

flowchart LR
    E["STATE<br/>Board + filters + sort"] --> R["render(state)<br/>draws the screen"]
    R --> P["SCREEN<br/>list, summary, counters"]
    P --> V["The user acts<br/>click, key, submit"]
    V --> M["Delegated handler<br/>calls the MODEL"]
    M --> N["NEW STATE<br/>board.changeStatus(...)"]
    N --> R

The rules of the cycle, which are worth respecting with discipline:

  • One single direction. The state feeds the screen; the screen is never the source of truth. If you need to know which tasks are done, you ask the Board, you do not count elements with the class task--done.
  • Handlers do not touch the DOM. They modify the state (through the model) and call render(). Just that one.
  • render is (almost) pure. Given the same state, it produces the same screen. Its only side effect is writing to the DOM.
  • The model still does not know a screen exists. Task and Board do not change a single line in this lesson.

  1. Templates with template literals and escapeHtml

There is a way of building HTML that turns out to be very readable: the template literals from 01-05, with the structure written as it is and the data interpolated.

function taskTemplate(task) {
  return `
    <li class="task task--${task.priority}" data-id="${task.id}">
      <span class="task__title">${task.title}</span>
      <span class="task__meta">${task.assignee} · ${task.estimatedHours} h</span>
      <button type="button" data-action="advance">Start</button>
    </li>`;
}

list.innerHTML = board.tasks.map(taskTemplate).join('');

It reads beautifully. And exactly as written, it is a vulnerability. In 06-07 Marta will be able to write the title of a task; if she writes <img src=x onerror="…">, that code will run in your page.

The only way of using this approach safely is to escape every interpolated piece of data, with no exceptions:

// js/view/dom.js
const ESCAPES = Object.freeze({
  '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
});

/** Turns the characters with meaning in HTML into their entities. */
export function escapeHtml(value) {
  return String(value).replace(/[&<>"']/g, (character) => ESCAPES[character]);
}
console.log(escapeHtml('<img src=x onerror="steal()">'));
// '&lt;img src=x onerror=&quot;steal()&quot;&gt;'   ← it will show as text, it will not run

console.log(escapeHtml('Inks & screens'));
// 'Inks &amp; screens'

Details that make this function correct and not a false sense of security:

  • & is escaped first in the order of the regular expression, because if it were escaped afterwards it would turn the already generated &lt; into &amp;lt;. Using replace with a replacement function avoids the problem at the root, because each character is processed exactly once.
  • The quotes are escaped too. Without them, a piece of data interpolated inside an attribute (data-id="${value}") could close the attribute and inject others. It is a real vector.
  • String(value) protects against null, undefined and numbers.

And a warning not to forget: escaping works for text content and quoted attributes. It is not enough if you interpolate inside a <script>, inside a style, or in an href/src attribute (where a javascript: is still dangerous). For those cases, do not interpolate: use nodes.

With the function, the template ends up like this:

import { escapeHtml as esc } from './dom.js';

function taskTemplate(task) {
  return `
    <li class="task task--${esc(task.priority)}" data-id="${esc(task.id)}">
      <span class="task__title">${esc(task.title)}</span>
      <span class="task__meta">${esc(task.assignee ?? 'unassigned')} · ${esc(task.estimatedHours)} h</span>
      <button type="button" data-action="advance">Start</button>
    </li>`;
}

It works, it is safe… and it depends on nobody ever forgetting an esc(). A single slip reopens the hole. That fragility is the decisive argument against this approach, and the reason the alternatives in the next section are preferable.

  1. Three ways of producing HTML, compared

Criterion Template literal + innerHTML createElement / buildElement <template> + cloneNode
Readability of the structure Very high Medium Very high (it is in the HTML)
Safety Depends on always escaping Safe by construction Safe (filled in with textContent)
References to the created nodes You have to look them up again You already have them Looked up inside the clone
Preserves nodes, focus and state No: destroys and recreates Yes, if you update instead of recreating Yes, the same
Cost Parsing HTML on every render Method calls Cloning (very cheap)
Conditional structures Easy with ternaries Easy with filter(Boolean) Awkward: you have to hide parts
Risk of human error High (forgetting an esc) Low Low

Operational conclusion for Nómada Tasks: <template> for the card's fixed structure, buildElement for the dynamic parts, and template literals only for constant markup with no data. It is the balance between readability and safety, and it eliminates the entire class of escaping errors.

  1. <template> for the cards

We pick up the template from 06-05 and give it its final shape, with the columns included:

<template id="task-template">
  <li class="task" tabindex="-1">
    <span class="task__title"></span>
    <span class="task__meta"></span>
    <ul class="task__tags"></ul>
    <button type="button" class="task__action" data-action="advance"></button>
    <button type="button" class="task__action" data-action="reopen">Reopen</button>
  </li>
</template>

<template id="column-template">
  <section class="column" aria-labelledby="">
    <h3 class="column__title"></h3>
    <p class="column__count"></p>
    <ul class="task-list"></ul>
    <p class="column__empty" hidden>There are no tasks in this column.</p>
  </section>
</template>

And the function that fills it in. Notice that it now updates an existing <li> if you pass one, and only clones when there is none: that dual capability is what will make the reconciliation of section 10 possible.

// js/view/card.js
import { $ } from './dom.js';
import { TODAY } from '../util/dates.js';
import { statusBadge } from '../util/format.js';

const PRIORITY_CLASSES = ['task--high', 'task--medium', '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'
});

/**
 * Creates or UPDATES the <li> of a task.
 * @param {Task} task
 * @param {HTMLElement|null} existing  if passed, it is reused instead of cloning
 */
export function paintCard(task, existing = null, today = TODAY) {
  const li = existing ?? $('#task-template').content.firstElementChild.cloneNode(true);
  const overdue = task.isOverdue(today);

  li.dataset.id = task.id;
  li.dataset.status = task.status;
  li.dataset.assignee = task.assignee ?? '';
  li.dataset.priority = task.priority;

  li.classList.remove(...PRIORITY_CLASSES);
  li.classList.add(`task--${task.priority}`);
  li.classList.toggle('task--done', task.status === 'done');
  li.classList.toggle('task--overdue', overdue);

  $('.task__title', li).textContent = task.title;
  $('.task__meta', li).textContent =
    `${statusBadge(task.status)} ${task.assignee ?? 'unassigned'} · ` +
    `${task.estimatedHours} h` + (overdue ? ` · overdue by ${Math.abs(task.daysLeft)} d` : '');

  const tags = $('.task__tags', li);
  tags.replaceChildren(...task.tags.map((text) => {
    const item = document.createElement('li');
    item.className = 'tag';
    item.textContent = text;
    return item;
  }));

  const advance = $('[data-action="advance"]', li);
  advance.textContent = LABEL[task.status];
  advance.disabled = NEXT[task.status] === null;
  advance.setAttribute('aria-label', `${LABEL[task.status]}: ${task.title}`);

  const reopen = $('[data-action="reopen"]', li);
  reopen.disabled = task.status !== 'in-progress';
  reopen.setAttribute('aria-label', `Send back to pending: ${task.title}`);

  return li;
}

A single function serves both for creating and for updating. That is exactly what a render that runs many times needs.

  1. Grouping the board by status with Object.groupBy

The Taller Nómada board has three columns: pending, in progress and done. Grouping is one line, with what you learned in 04-05:

const COLUMNS = [
  { status: 'pending',     title: 'Not started' },
  { status: 'in-progress', title: 'Under way' },
  { status: 'done',        title: 'Completed' }
];

const byStatus = Object.groupBy(board.tasks, (t) => t.status);
console.log(Object.keys(byStatus));                   // ['in-progress', 'pending', 'done']
console.log(byStatus.pending.length);                 // 3  ← ids 2, 3 and 6
console.log(byStatus['in-progress'].map((t) => t.id)); // [1, 5]
console.log(byStatus.done.map((t) => t.id));          // [4]

Two warnings about Object.groupBy:

  • It only creates the keys that appear. If no task is done, byStatus.done is undefined, not an empty array. Use ?? [] always.
  • The object it returns has the null prototype, so it has no hasOwnProperty and no toString. To check keys, Object.hasOwn(byStatus, 'done') or simply the ??.
const pending = byStatus.pending ?? [];             // ✓ never undefined

With that, drawing the three columns is straightforward:

function renderColumns(container, tasks, today) {
  const byStatus = Object.groupBy(tasks, (t) => t.status);

  container.replaceChildren(...COLUMNS.map(({ status, title }) => {
    const column = $('#column-template').content.firstElementChild.cloneNode(true);
    const inStatus = byStatus[status] ?? [];

    column.dataset.status = status;
    $('.column__title', column).textContent = title;
    $('.column__title', column).id = `column-${status}`;
    column.setAttribute('aria-labelledby', `column-${status}`);

    const hours = inStatus.reduce((sum, t) => sum + t.estimatedHours, 0);
    $('.column__count', column).textContent =
      `${inStatus.length} task${inStatus.length === 1 ? '' : 's'} · ${hours} h`;

    $('.task-list', column).replaceChildren(...inStatus.map((t) => paintCard(t, null, today)));
    $('.column__empty', column).hidden = inStatus.length > 0;

    return column;
  }));
}

With the canonical backlog, the result is:

Not started · 3 tasks · 25 h     (Signage 6, Bookings website 14, Carpentry 5)
Under way   · 2 tasks · 20 h     (Multipurpose room 12, Bookbinding guide 8)
Completed   · 1 task  · 3 h      (Ink inventory)

And 25 + 20 = 45 h open, out of 48 h in total. The canonical numbers, now spread across columns.

The minimal CSS so they look like columns:

.board { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem; }
.column { border: 1px solid var(--border); border-radius: 0.5rem; padding: 0.75rem; }
.column__title { margin: 0 0 0.25rem; font-size: 1rem; }
.column__count { margin: 0 0 0.75rem; color: var(--gray); font-size: 0.85rem; }
.column__empty { color: var(--gray); font-style: italic; }
.task__tags { list-style: none; display: flex; gap: 0.3rem; padding: 0; margin: 0.3rem 0 0; }
.tag { font-size: 0.75rem; background: #f3f4f6; border-radius: 999px; padding: 0.1rem 0.5rem; }

  1. Empty states, counters and summary

A detail that separates a finished interface from a prototype: what you see when there is nothing. An empty column with no message looks like a loading error.

You have already solved it above with $('.column__empty', column).hidden = inStatus.length > 0, but it is worth telling three different situations apart, because the right message is not the same:

Situation Appropriate message
There are no tasks in this column "There are no tasks in this column."
There are tasks, but the filter hides them all "No task matches the filter. Clear filters"
The board is completely empty "There are no tasks yet. Create the first one with the form."
function emptyMessage(status, hasAnyTasks, hasActiveFilter) {
  if (!hasAnyTasks) return 'There are no tasks yet. Create the first one with the form.';
  if (hasActiveFilter) return 'No task matches the current filter.';
  return 'There are no tasks in this column.';
}

And the global summary, which reuses the model's method:

function renderSummary(element, board, today) {
  const { total, open, totalHours, openHours, overdue, effort } = board.summary(today);
  element.textContent =
    `${total} task${total === 1 ? '' : 's'} · ${open} open · ${openHours} of ${totalHours} h · ` +
    `${overdue} overdue · effort ${effort}`;
}
// 6 tasks · 5 open · 45 of 48 h · 1 overdue · effort 124

That element carries role="status" in the HTML (06-01), so a screen reader announces the new summary every time it changes, without stealing focus.

  1. The problem of redrawing everything

And now the bad news. replaceChildren(...) destroys all the nodes and creates others. That has four visible consequences:

  1. Focus is lost. If Marta had tabbed her way to the "Mark done" button of the third task and presses Enter, the render leaves her with focus on document.body. Her next Tab takes her to the top of the page.
  2. Scrolling is lost inside containers with their own scrollbar.
  3. Browser state is lost: selected text, open <details>, the cursor position inside an <input> that happened to be in the list.
  4. CSS transitions are restarted, because the elements are new.
render();
console.log(document.activeElement.tagName);   // 'BODY'  ✗ focus is gone

There are two strategies for solving it.

Strategy A: save and restore. Before redrawing, note what was focused; afterwards, look it up again by its stable key and give it back its focus.

function withFocusPreserved(render) {
  const active = document.activeElement;
  const taskId = active?.closest?.('[data-id]')?.dataset.id ?? null;
  const action = active?.dataset?.action ?? null;
  const scroll = container.scrollTop;

  render();

  container.scrollTop = scroll;
  if (taskId !== null) {
    const target = $(`[data-id="${taskId}"] [data-action="${action}"]`) ??
                   $(`[data-id="${taskId}"]`);
    if (target !== null && !target.disabled) target.focus();
  }
}

It works and it is simple, but it is a patch: the more things there are to preserve, the more fragile it becomes.

Strategy B: do not destroy what has not changed. This is the good one, and it is what frameworks do. Instead of emptying and recreating, you compare the list of current nodes with the new data and reuse every node that is still valid, updating only its content.

  1. Partial updating: keys and node reuse

To reuse a node you have to be able to identify it. Position will not do: if the first task disappears, all the others shift and the second one's node would come to represent the third, with focus and animations in the wrong place.

What does work is a stable key: an identifier that belongs to the data, not to its position. In Nómada Tasks you have had it since 06-02: data-id.

flowchart TD
    D["New data<br/>ids: 3, 6, 1"] --> C{"Is there already a node<br/>with that data-id?"}
    C -- "Yes" --> A["Update its content<br/>(preserves focus and state)"]
    C -- "No" --> N["Create the new card"]
    A --> O["Place it in the right order"]
    N --> O
    O --> S["Remove the leftover nodes<br/>(ids that are gone)"]

  1. reconcile(list, data)

Here is the implementation, and it is shorter than it looks:

// js/view/dom.js

/**
 * Synchronizes a container's children with a list of data, reusing the
 * nodes that already exist. Each item must have a stable key.
 *
 * @param {HTMLElement} container
 * @param {Array} data
 * @param {(item) => string|number} key   stable identifier of the item
 * @param {(item, existingNode|null) => HTMLElement} paint  creates or updates the node
 */
export function reconcile(container, data, key, paint) {
  // 1 · We index the current nodes by their key (the reduce from 04-05)
  const existing = new Map(
    [...container.children].map((node) => [node.dataset.key ?? node.dataset.id, node])
  );

  // 2 · We walk the data in its final order
  let reference = container.firstElementChild;

  for (const item of data) {
    const k = String(key(item));
    const previous = existing.get(k) ?? null;
    const node = paint(item, previous);        // reuses if previous !== null
    existing.delete(k);                        // marked as "still alive"

    if (node !== reference) {
      container.insertBefore(node, reference); // moves or inserts in the right place
    } else {
      reference = reference.nextElementSibling; // it was already correctly placed
    }
  }

  // 3 · Whatever is left in the map is no longer in the data: out
  for (const leftover of existing.values()) leftover.remove();
}

A line-by-line explanation of what it does and why:

  • Step 1: it builds a Map of key → node with what is on screen. It is exactly the pattern of indexing by id from 04-05, applied to nodes instead of objects.
  • Step 2: it walks the data in the order they should end up in. For each one, if a node with that key already existed it reuses it (paint(item, previous) updates it instead of cloning), and if not, it creates a new one. Then it places it in its position with insertBefore, which —remember 06-05— moves the node if it was already in the tree.
  • Step 3: what is left in the Map are nodes whose key no longer appears in the data. They are removed.

The result: the nodes of the tasks that are still there are never destroyed. They keep their focus, their transitions and any browser state.

// Usage, with the same paintCard that serves to create and to update
reconcile(
  list,
  visible,
  (task) => task.id,
  (task, node) => paintCard(task, node, state.today)
);

Check it: put focus on the "Start" button of the third task, press Enter and observe that focus is still there after the render, with the button already changed to "Mark done". With replaceChildren it would have disappeared.

The limits of this implementation. It is deliberately simple and is not a complete reconciliation algorithm: it does not detect moves optimally (it may move more nodes than strictly necessary) and it only works with direct children that have a key. For a task list it is more than enough. For a large interface, with nested trees and components, doing this by hand becomes unfeasible, and that is where frameworks come in: React, Vue and Angular solve this exact problem —and that is why all of them ask you for a key in lists, which is literally this data-id. You will see it in Why Frameworks Exist, and you will arrive at that lesson knowing what problem they solve, which is the only way of really understanding them.

  1. Sorting and filtering in the view without touching the model

Filtering and sorting are presentation decisions. The Board must not find out: if Lucía filters by her name, the board still has six tasks and 45 h open; the only thing that changes is what is shown.

// js/view/board-view.js
const SORTS = Object.freeze({
  priority: (a, b) => WEIGHTS[b.priority] - WEIGHTS[a.priority] || a.id - b.id,
  date:     (a, b) => a.dueDate.localeCompare(b.dueDate),
  hours:    (a, b) => b.estimatedHours - a.estimatedHours,
  title:    (a, b) => a.title.localeCompare(b.title, 'en')
});

/** Applies filters and sorting WITHOUT modifying the board. Returns a new array. */
function visibleTasks(state) {
  const { assignee, text } = state.filters;

  return state.board.tasks                           // ← defensive copy from the model (05-03)
    .filter((t) => assignee === null || t.assignee === assignee)
    .filter((t) => text === '' || t.title.toLowerCase().includes(text.toLowerCase()))
    .sort(SORTS[state.sort] ?? SORTS.priority);
}

Three important points:

  • board.tasks already returns a copy ([...this.#tasks], the encapsulation of 05-03), so the .sort(), which mutates, does not touch the model's internal array. If the getter returned the real array, this sort would reorder the model: a subtle, unpleasant bug.
  • The priority comparator uses || as a tie-breaker: if two tasks have the same priority, they are ordered by id. Without a tie-breaker, the order among equals would depend on the algorithm and could change between renders, producing visual jumps.
  • localeCompare(text, 'en') sorts accented characters correctly, something the string < operator does not do.

  1. Nómada Tasks: view/board-view.js

The complete module, which brings everything above together:

// js/view/board-view.js
import { $, $$, reconcile } from './dom.js';
import { paintCard } from './card.js';
import { WEIGHTS } from '../util/format.js';
import { TODAY } from '../util/dates.js';
import { EVENTS, emit } from './events.js';

const COLUMNS = [
  { status: 'pending',     title: 'Not started' },
  { status: 'in-progress', title: 'Under way' },
  { status: 'done',        title: 'Completed' }
];

const SORTS = Object.freeze({
  priority: (a, b) => WEIGHTS[b.priority] - WEIGHTS[a.priority] || a.id - b.id,
  date:     (a, b) => a.dueDate.localeCompare(b.dueDate),
  hours:    (a, b) => b.estimatedHours - a.estimatedHours
});

export class BoardView {
  #container;
  #summary;
  #state;

  constructor({ container, summary, board, today = TODAY }) {
    this.#container = container;
    this.#summary = summary;
    this.#state = {
      board,
      today,
      filters: { assignee: null, text: '' },
      sort: 'priority'
    };
    this.#prepareColumns();
  }

  /** The columns are created ONCE; afterwards only their lists are reconciled. */
  #prepareColumns() {
    this.#container.replaceChildren(...COLUMNS.map(({ status, title }) => {
      const column = $('#column-template').content.firstElementChild.cloneNode(true);
      column.dataset.status = status;
      const heading = $('.column__title', column);
      heading.textContent = title;
      heading.id = `column-${status}`;
      column.setAttribute('aria-labelledby', `column-${status}`);
      $('.task-list', column).setAttribute('aria-live', 'polite');
      return column;
    }));
  }

  /** Changes a part of the view state and redraws. */
  update(changes) {
    Object.assign(this.#state, changes);
    if (changes.filters) Object.assign(this.#state.filters, changes.filters);
    this.render();
  }

  #visible() {
    const { assignee, text } = this.#state.filters;
    return this.#state.board.tasks
      .filter((t) => assignee === null || t.assignee === assignee)
      .filter((t) => text === '' || t.title.toLowerCase().includes(text.toLowerCase()))
      .sort(SORTS[this.#state.sort] ?? SORTS.priority);
  }

  render() {
    const visible = this.#visible();
    const byStatus = Object.groupBy(visible, (t) => t.status);
    const hasFilter = this.#state.filters.assignee !== null || this.#state.filters.text !== '';

    for (const { status } of COLUMNS) {
      const column = $(`.column[data-status="${status}"]`, this.#container);
      const inStatus = byStatus[status] ?? [];
      const hours = inStatus.reduce((sum, t) => sum + t.estimatedHours, 0);

      $('.column__count', column).textContent =
        `${inStatus.length} task${inStatus.length === 1 ? '' : 's'} · ${hours} h`;

      // Reconciliation: the nodes that are still alive are NOT destroyed
      reconcile(
        $('.task-list', column),
        inStatus,
        (task) => task.id,
        (task, node) => paintCard(task, node, this.#state.today)
      );

      const empty = $('.column__empty', column);
      empty.hidden = inStatus.length > 0;
      empty.textContent = hasFilter
        ? 'No task matches the current filter.'
        : 'There are no tasks in this column.';
    }

    const r = this.#state.board.summary(this.#state.today);
    this.#summary.textContent =
      `${r.total} task${r.total === 1 ? '' : 's'} · ${r.open} open · ${r.openHours} of ${r.totalHours} h · ` +
      `${r.overdue} overdue · effort ${r.effort}` +
      (hasFilter ? ` · showing ${visible.length}` : '');

    emit(this.#container, EVENTS.BOARD_UPDATED, { ...r, shown: visible.length });
  }
}

And the entry point, which by now is pure wiring:

// js/app.js
import { Board } from './model/board.js';
import { createBacklog } from './data/backlog.js';
import { TODAY } from './util/dates.js';
import { BoardView } from './view/board-view.js';
import { $ } from './view/dom.js';

const board = new Board('Taller Nómada', createBacklog());
const view = new BoardView({
  container: $('.board'),
  summary: $('#summary'),
  board,
  today: TODAY
});
view.render();

// One single delegated handler for the WHOLE board (06-04)
$('.board').addEventListener('click', (event) => {
  const button = event.target.closest('button[data-action]');
  if (button === null) return;

  const id = Number(button.closest('[data-id]').dataset.id);
  const task = board.findById(id);
  const destination = button.dataset.action === 'advance'
    ? { pending: 'in-progress', 'in-progress': 'done', done: null }[task.status]
    : (task.status === 'in-progress' ? 'pending' : null);
  if (destination === null) return;

  board.changeStatus(id, destination);   // 1 · change the STATE
  view.render();                         // 2 · redraw
});

// The filters only change the view state; the model does not even find out
$('.filters').addEventListener('click', (event) => {
  const button = event.target.closest('.filter');
  if (button === null) return;
  $$('.filter').forEach((b) => b.setAttribute('aria-pressed', String(b === button)));
  view.update({ filters: { assignee: button.dataset.assignee || null } });
});

The whole cycle from section 2, in twenty lines: the delegated handler touches the model and calls render(); render() draws what is there. When the carpentry task is marked as done, the card moves from the "Not started" column to "Completed" without being destroyed, the counters go from 3 tasks · 25 h to 2 tasks · 20 h and from 1 task · 3 h to 2 tasks · 8 h, the summary drops from 45 to 40 h open and from 1 overdue to 0, and focus stays exactly where it was.

Common Mistakes and Tips

  • Interpolating unescaped data into a text template. One forgotten ${} is enough to open an XSS. If you choose this approach, escape always; if you can, use <template> or nodes and eliminate the whole class of errors.
  • Using the array index as the key. data-id="${index}" looks like it works until an element in the middle is deleted: from then on every node represents the wrong item. The key must belong to the data.
  • Forgetting ?? [] with Object.groupBy. It only creates the keys that appear; a column with no tasks gives undefined and the following .map throws a TypeError.
  • Sorting the model's array. sort mutates. It works here because board.tasks returns a copy; if some getter returned the internal array, you would be reordering the model from the view. When in doubt, [...list].sort(...).
  • Redrawing inside a fast event loop. A complete render() for every keystroke in a search field is wasted work. The solution is the debounce you will see in 06-07.
  • Putting handlers inside render. If render registers handlers, every call adds another one and you end up with the action running five times. Handlers are registered once, by delegation, outside the render.
  • Redrawing and losing focus without realizing. It is a serious, silent accessibility failure: mouse users never notice it. Always test it with the Tab key.
  • Tip: keep render() free of hidden effects. Let it only read the state and write to the DOM. If it makes requests, changes the model or triggers actions, it stops being predictable and debugging becomes very hard.
  • Tip: keep the view state in a single object. Having filters, sort and today in a single #state makes answering "why does it look like this?" trivial: you print the object and you know.

Exercises

Exercise 1 · Search by title

Add to the HTML an <input type="search" id="search"> with its <label> and wire it up so that typing filters the tasks by title. It must use state.filters.text, must not touch the model, and must show in the summary how many are being seen. Check with the Tab key that focus does not leave the search field while typing, and explain why reconciliation is essential here.

Exercise 2 · Sorting with a <select>

Add a <select id="sort"> with the options priority, due date and hours, and make the cards reorder when it changes. Then check, with the DevTools, that reordering moves the nodes instead of recreating them: set a data-mark on an <li> from the console, reorder, and verify that the mark is still there.

Exercise 3 · Reconciliation with deletion

Add a data-action="delete" button to the card and a removeTask(id) method to Board. Check that after deleting a task from the middle, the remaining cards keep their nodes (they are not recreated) and that focus moves to the equivalent button of the next task. Explain what would have happened if reconcile used position instead of data-id.

Solutions

Exercise 1

<label for="search">Search by title</label>
<input type="search" id="search" placeholder="screen printing, web, carpentry…">
$('#search').addEventListener('input', (event) => {
  view.update({ filters: { text: event.target.value.trim() } });
});

Reconciliation is essential here for a very concrete reason: the input event fires on every keystroke, so every letter causes a render(). If that render did a replaceChildren, it would destroy and recreate every card twenty times while Marta types "screen printing". It would not lose the <input>'s focus —that is outside the reconciled container—, but it would waste an enormous amount of work, restart the CSS transitions on every key and produce a perfectly visible flicker. With reconcile, each keystroke only removes the cards that stop matching and brings back the ones that match again.

Typing "carpent", the summary shows 6 tasks · 5 open · 45 of 48 h · 1 overdue · effort 124 · showing 1: the model's numbers do not change, only the count of what is shown. It is exactly the separation we were after.

Exercise 2

<label for="sort">Sort by</label>
<select id="sort">
  <option value="priority">Priority</option>
  <option value="date">Due date</option>
  <option value="hours">Estimated hours</option>
</select>
$('#sort').addEventListener('change', (event) => {
  view.update({ sort: event.target.value });
});

Check that the nodes move and are not recreated:

// In the console, before reordering:
document.querySelector('[data-id="6"]').dataset.mark = 'witness';

// Change the <select> to "Due date" and check:
document.querySelector('[data-id="6"]').dataset.mark;   // 'witness' ✓ it is the SAME node

If the mark survives, it means reconcile reused the node and only relocated it with insertBefore. With replaceChildren the mark would have disappeared, because the <li> would be a different object. This witness-attribute trick is, incidentally, an excellent debugging technique for verifying any node-reuse strategy.

Note: change is the right event for a <select>; input works too, but change better expresses the intention of "an option has been chosen".

Exercise 3

// model/board.js — the new method, in the MODEL
removeTask(id) {
  const index = this.#tasks.findIndex((t) => t.id === id);
  if (index === -1) throw new ValidationError(`Task ${id} does not exist.`, 'id', id);
  return this.#tasks.splice(index, 1)[0];
}
// app.js — inside the delegated handler
if (button.dataset.action === 'delete') {
  const li = button.closest('li[data-id]');
  const next = li.nextElementSibling ?? li.previousElementSibling;
  const action = button.dataset.action;

  board.removeTask(id);
  view.render();

  // The 'next' node survives the render thanks to reconciliation
  (next?.querySelector(`[data-action="${action}"]`) ?? $('#search')).focus();
  return;
}

If reconcile identified the nodes by position, deleting the task in the middle would cause the following: the node occupying position 2 would be filled with the data of the task that was previously in position 3, the one in 3 with the data from 4, and so on to the end; the last node would be removed. Visually the result would be correct, but every node would have changed its data: focus, which was on the card in position 3, would end up on a card that now shows a different task; any CSS transition under way would be applied to the wrong element; and any browser state tied to a node (an expanded <details>, for instance) would be attributed to the wrong task.

With data-id as the key, each task's node stays its own whatever happens: only the node whose key disappears from the data is removed. This is exactly why React, Vue and Angular insist so much on the key prop for lists, and seeing it from the inside is going to save you a lot of time when you get to Module 10.

Conclusion

You have gone from manipulating individual nodes to having an architecture. The axis is the render(state) function, which describes how the screen should look given a state, and the cycle state → render → event → new state → render, with its rules: one single direction, handlers do not touch the DOM (they change the state and call render), and the model still does not know a screen exists. The advantage is not writing less, but that the interface cannot get out of sync with the data, which is the kind of error hardest to chase down in an imperative application.

You know the three ways of producing HTML and the criterion for choosing. Template literals are the most readable and the most dangerous: they demand escaping every piece of data with an escapeHtml function that translates & < > " ', and one slip is enough to open an XSS. Building with nodes is safe by definition. And <template> with content.cloneNode(true) combines the readability of having the structure in the HTML with the safety of filling it in through textContent, which is the option adopted in the project. On top of it you have written paintCard(task, existing), a function that creates or updates depending on whether you pass it a previous node: the property that makes everything else possible.

You know how to group the board into columns with Object.groupBy, with the reminder that it only creates the keys present and that ?? [] is needed; how to draw counters per column (3 tasks and 25 h not started, 2 and 20 h under way, 1 and 3 h completed, adding up to the usual 45 h open); and how to take care of empty states with different messages depending on whether it is an empty column, a filter with no results or a brand-new board. And, above all, you know what happens when you redraw everything with replaceChildren: focus, scrolling, selection and browser state are lost, an accessibility failure that mouse users never perceive. The answer lies in stable keys: reconcile(container, data, key, paint) indexes the existing nodes in a Map by their data-id, reuses the ones still alive by updating their content, relocates them with insertBefore and removes the leftovers. The surviving nodes keep their identity, and with it their focus and their transitions. Filtering and sorting, finally, are view decisions: they live in its state, they operate on the copy returned by board.tasks and they never alter the model.

That twenty-line reconcile is also a lesson about the craft: doing it well for a flat list with a key is manageable; doing it for nested trees of components, with arbitrary order and partial updates, is the work that React, Vue and Angular do. You will arrive at Module 10 knowing exactly what problem they solve and why they all ask you for a key.

One last piece remains, and it is the one that turns the board into a real tool. Marta can move tasks around and filter them, but she cannot create any: the backlog is still the one in data/backlog.js. Adding a task means a form, and a form brings a world of its own: accessible fields with their labels, values that always arrive as text, native browser validation versus validation in JavaScript, business rules that must go on living in the model, error messages a screen reader can announce, and focus put where it belongs when something fails. All of that is Handling and Validating Forms, the last lesson of the module.

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