The previous lesson ended with an honest problem: connectActions registers one handler for every button of every task, and as soon as you start creating the <li>s from JavaScript, the new elements will be born with no handler. The solution is not to reconnect everything each time, but to understand that an event does not happen on one element alone: it travels through the DOM tree, from the root down to the target and back. By taking advantage of that journey you can place a single handler on the container that attends to all of its children, present and future. In this lesson you will learn the three phases of that journey, the three ways of interrupting it (and how they differ), the delegation pattern —probably the highest-value technique in the whole module— and custom events, which will let the view and the model communicate without knowing each other.
Contents
- The journey of an event: capture, target and bubbling
- A demonstration on three levels
- Listening in the capture phase
- Stopping the journey:
stopPropagationandstopImmediatePropagation preventDefaultis not the same thing (comparison table)- Events that do not bubble, and the equivalents that do
- Event delegation
event.target.closest()anddataset: the complete pattern- Nómada Tasks: the board with a single handler
- Custom events:
CustomEventanddetail - Decoupling the view from the model with events
AbortController: removing many handlers at once- Common Mistakes and Tips
- Exercises
- Conclusion
- The journey of an event: capture, target and bubbling
When you click the button of a task, that click does not happen "on the button" and that is that. It happens on the button, which is inside an <li>, which is inside a <ul>, which is inside a <section>… The browser considers that the event concerns that whole chain, and it walks it in three phases:
- Capture (capturing): the event descends from
windowdown to the target element, passing through all of its ancestors. - Target: the event reaches the element where it actually happened.
- Bubbling: the event rises from the target up to
window, again through all of its ancestors.
flowchart TD
W1["window"] --> D1["document"]
D1 --> S1["section.board"]
S1 --> U1["ul#task-list"]
U1 --> L1["li[data-id=6]"]
L1 --> B["button.task__action<br/>◀ PHASE 2 · TARGET"]
B --> L2["li[data-id=6]"]
L2 --> U2["ul#task-list"]
U2 --> S2["section.board"]
S2 --> D2["document"]
D2 --> W2["window"]
W1 -.- CAP["PHASE 1 · CAPTURE<br/>from the outside in"]
W2 -.- BUR["PHASE 3 · BUBBLING<br/>from the inside out"]
By default, addEventListener registers the handler in the bubbling phase. That is why, in the previous lesson, a handler placed on the <li> fired when you clicked the button it contains: the event was born on the button and bubbled up to the <li>.
Two properties of the Event object describe this journey:
event.eventPhase:1in capture,2at the target,3in bubbling.event.composedPath(): the complete array of nodes it passes through, from the target upward.
button.addEventListener('click', (e) => {
console.log(e.composedPath().map((n) => n.nodeName ?? n.constructor.name));
// ['BUTTON', 'LI', 'UL', 'SECTION', 'MAIN', 'BODY', 'HTML', '#document', 'Window']
});
- A demonstration on three levels
The best way to internalize this is to see it. Register a handler at each level and make a single click on the button:
const section = document.querySelector('.board');
const ul = document.querySelector('#task-list');
const li = document.querySelector('[data-id="6"]');
const button = li.querySelector('.task__action');
function spy(name) {
return (event) => {
const phases = { 1: 'CAPTURE', 2: 'TARGET', 3: 'BUBBLE' };
console.log(`${phases[event.eventPhase]} · ${name} · target=${event.target.tagName}`);
};
}
section.addEventListener('click', spy('section'));
ul.addEventListener('click', spy('ul'));
li.addEventListener('click', spy('li'));
button.addEventListener('click', spy('button'));A click on the button prints:
TARGET · button · target=BUTTON BUBBLE · li · target=BUTTON BUBBLE · ul · target=BUTTON BUBBLE · section · target=BUTTON
Four handlers executed with a single click. Fundamental observations:
targetisBUTTONin all four. The target does not change during the journey: it is always where the event happened. What changes iscurrentTarget, which on each line is the element where you registered that specific handler.- No CAPTURE line appears, because the four handlers were registered in bubbling (the default value).
- The order is from the inside out. First the deepest one, then its ancestors.
This is exactly the property that makes delegation possible: a handler on the <ul> is already receiving the clicks of all its descendants.
- Listening in the capture phase
With { capture: true } the handler is registered in the descending phase:
section.addEventListener('click', spy('section CAPTURE'), { capture: true });
ul.addEventListener('click', spy('ul CAPTURE'), { capture: true });
li.addEventListener('click', spy('li')); // bubbling
button.addEventListener('click', spy('button'));Now the same click prints:
CAPTURE · section CAPTURE · target=BUTTON CAPTURE · ul CAPTURE · target=BUTTON TARGET · button · target=BUTTON BUBBLE · li · target=BUTTON
Capture goes from the outside in and always happens before bubbling. There is also an inherited shorthand, addEventListener('click', fn, true), with the third parameter as a boolean; today the options object is preferred for readability.
When is capture used? Rarely, and always for a specific reason:
- To intercept before anybody else, for example to record analytics or to block interaction with a whole area of the interface while something is being saved.
- To capture events that do not bubble, such as
focusorerroron images, from a container. It is the trick in section 6.
In 95 % of cases, bubbling is what you want.
- Stopping the journey:
stopPropagation and stopImmediatePropagation
stopPropagation and stopImmediatePropagationevent.stopPropagation() stops the journey: the handlers still ahead in the tree do not run.
li.addEventListener('click', (event) => {
event.stopPropagation();
console.log('the li cuts it off here');
});
// A click on the button prints:
// TARGET · button
// the li cuts it off here
// … and NOTHING else: 'ul' and 'section' never find out.event.stopImmediatePropagation() is more forceful: as well as stopping the journey upward, it prevents the other handlers on the same element from running.
li.addEventListener('click', (e) => { e.stopImmediatePropagation(); console.log('first'); });
li.addEventListener('click', () => console.log('second')); // ✗ never runs
// With stopPropagation() instead of stopImmediatePropagation(),
// 'second' WOULD run, and only the climb toward the <ul> would be cut off.Important warning: stopPropagation is dangerous. It is a decision you take in one component and that affects the whole rest of the application, including the part you have not written yet. Typical scenario: you put stopPropagation on a card so that a click does not trigger something on the container; three weeks later somebody adds a handler on document to close a dropdown menu when clicking outside, and that menu stops closing on top of your cards. The failure is impossible to find because there is no error.
The alternative is almost always better: let the handler above check whether the event concerns it.
// ✗ The child imposes its criterion on everybody
button.addEventListener('click', (e) => e.stopPropagation());
// ✓ The parent decides with a guard
ul.addEventListener('click', (event) => {
if (event.target.closest('.task__action') === null) return; // not my business
// …
});
preventDefault is not the same thing (comparison table)
preventDefault is not the same thing (comparison table)These three methods get confused constantly, and they do completely different things:
| Method | What it does | What it does NOT do |
|---|---|---|
preventDefault() |
Cancels the browser's default action (following a link, submitting a form, scrolling with the space bar) | It does not stop propagation: the event goes on rising |
stopPropagation() |
Prevents the event from continuing to the following elements on the journey | It does not cancel the default action; it does not prevent the other handlers on the same element |
stopImmediatePropagation() |
The above and on top of that prevents the other handlers on the same element | It does not cancel the default action |
Practical corollary:
link.addEventListener('click', (event) => {
event.stopPropagation();
// The browser STILL navigates: preventDefault() is missing
});
link.addEventListener('click', (event) => {
event.preventDefault();
// The event STILL bubbles up to document: that is normal and almost always desirable
});A useful detail: when a handler has called preventDefault(), later handlers on the journey can detect it with event.defaultPrevented, which lets you write cooperative code instead of code that steps on itself.
- Events that do not bubble, and the equivalents that do
Not every event bubbles. The most important ones that do not:
| Event that does not bubble | Equivalent that does bubble | Note |
|---|---|---|
focus |
focusin |
Fires before the element receives focus |
blur |
focusout |
Fires before it loses focus |
mouseenter |
mouseover |
Remember that mouseover also fires with the children |
mouseleave |
mouseout |
The same |
load, error (on <img>, <script>) |
— | They can only be caught with { capture: true } |
A direct consequence: you cannot delegate focus or blur, because they never reach the container.
// ✗ Does not work: 'focus' does not bubble
form.addEventListener('focus', (e) => console.log('focus on', e.target.name));
// ✓ Option A: use the equivalent that does bubble
form.addEventListener('focusin', (e) => console.log('focus on', e.target.name));
// ✓ Option B: listen in the capture phase
form.addEventListener('focus', (e) => console.log('focus on', e.target.name), { capture: true });Option A is the recommended one: it is clearer and does not force you to reason about phases.
For load and error on images only capture is left, and it turns out to be very practical for detecting every broken image on the page in one go:
document.addEventListener('error', (event) => {
if (event.target.tagName === 'IMG') {
event.target.classList.add('broken-image');
}
}, { capture: true });
- Event delegation
Delegation consists of registering a single handler on a common ancestor and deciding inside what to do depending on where the event came from. It takes advantage of the bubbling from section 1.
flowchart TD
subgraph WITHOUT["Without delegation · 6 handlers"]
U1["ul#task-list"] --> A1["li 1 → handler"]
U1 --> A2["li 2 → handler"]
U1 --> A3["li 3 → handler"]
U1 --> A4["li 4 → handler"]
U1 --> A5["li 5 → handler"]
U1 --> A6["li 6 → handler"]
end
subgraph WITH["With delegation · 1 handler"]
U2["ul#task-list<br/>★ single handler"] --> B1["li 1"]
U2 --> B2["li 2"]
U2 --> B3["li 3"]
U2 --> B4["li 4"]
U2 --> B5["li 5"]
U2 --> B6["li 6"]
end
The three advantages, in order of importance:
- It works with elements that do not exist yet. That is the main reason. When in 06-05 you create a new
<li>and insert it into the<ul>, its clicks will bubble up to the handler that was already in place. Nothing has to be reconnected, and therefore there is no risk of duplicating handlers or of forgetting one. - Less memory and less registration work. One handler instead of two hundred. With six tasks the difference is irrelevant; with a long list, it is not. The question is dealt with in depth in Memory Management and Efficient DOM Manipulation.
- A single place to read the interaction logic. The entire response to the list's clicks is in one function, not spread over six places.
It also has drawbacks worth knowing about: the handler receives all the clicks in the area, so it needs a clear guard at the start; and it does not work for events that do not bubble (section 6).
event.target.closest() and dataset: the complete pattern
event.target.closest() and dataset: the complete patternA delegated handler always has the same shape, and it is worth memorizing:
ul.addEventListener('click', (event) => {
// 1 · Does the click come from an element I care about?
const button = event.target.closest('[data-action]');
if (button === null) return; // guard: not my business
// 2 · Is it inside my container? (protects against odd cases)
if (!ul.contains(button)) return;
// 3 · Which task does it belong to?
const li = button.closest('li[data-id]');
const id = Number(li.dataset.id); // explicit conversion!
// 4 · Which action has to run?
const action = button.dataset.action; // 'advance' | 'delete' | …
console.log({ id, action });
});Every step has its reason:
event.targetis the deepest element, and that is the problemclosest()solves. If your button contains a<span>with an icon, thetargetof a click on the icon will be the<span>, not the button. Checkingevent.target.matches('[data-action]')would fail in that case;closest()climbs until it finds the button and always works.- The
=== nullguard is mandatory. A click on the gap between two tasks also reaches your handler, and without the guard you would get aTypeError. dataset.actionturns the handler into a generic dispatcher: there is no branch per button, but a piece of data in the HTML that says what to do. It is the lookup dictionary of 02-03 applied to the interface.Number(li.dataset.id)is the boundary conversion from 06-02: the page speaks in strings, the model in numbers.
- Nómada Tasks: the board with a single handler
We rewrite the controller from the previous lesson. Now the HTML includes two actions per task and uses data-action:
<ul id="task-list" class="task-list">
<li class="task" data-id="1">
<span class="task__title"></span>
<span class="task__meta"></span>
<button type="button" class="task__action" data-action="advance"></button>
<button type="button" class="task__action" data-action="reopen">Reopen</button>
</li>
<!-- … the rest of the tasks, with the same structure … -->
</ul>And the complete controller, with a single addEventListener:
// js/view/controller.js
import { TODAY } from '../util/dates.js';
import { paintTask, paintSummary } from './paint.js';
const NEXT = Object.freeze({ pending: 'in-progress', 'in-progress': 'done', done: null });
const LABEL = Object.freeze({ pending: 'Start', 'in-progress': 'Mark done', done: 'Completed' });
/** Translates an interface action into the model's destination status. */
function targetStatus(action, task) {
if (action === 'advance') return NEXT[task.status];
if (action === 'reopen') return task.status === 'in-progress' ? 'pending' : null;
return null;
}
export function connectBoard({ list, summary, board, today = TODAY, signal }) {
/** Refreshes a whole <li>: text, classes and buttons. */
function refresh(li, task) {
paintTask(li, task, today);
const advance = li.querySelector('[data-action="advance"]');
advance.textContent = LABEL[task.status];
advance.disabled = NEXT[task.status] === null;
advance.setAttribute('aria-label', `${LABEL[task.status]}: ${task.title}`);
const reopen = li.querySelector('[data-action="reopen"]');
reopen.disabled = task.status !== 'in-progress';
reopen.setAttribute('aria-label', `Send back to pending: ${task.title}`);
}
// ── ONE SINGLE HANDLER FOR THE WHOLE LIST ────────────────────────────────
list.addEventListener('click', (event) => {
const button = event.target.closest('button[data-action]');
if (button === null || !list.contains(button)) return; // not my business
const li = button.closest('li[data-id]');
const task = board.findById(Number(li.dataset.id));
if (task === null) return;
const destination = targetStatus(button.dataset.action, task);
if (destination === null) return;
try {
board.changeStatus(task.id, destination); // R6: the model validates the transition
} catch (error) {
console.error(error.describe?.() ?? error.message);
return;
}
refresh(li, task);
paintSummary(summary, board, today);
// We notify the rest of the application (section 11)
li.dispatchEvent(new CustomEvent('task:changed', {
bubbles: true,
detail: { id: task.id, status: task.status, title: task.title }
}));
}, { signal });
// Initial painting
for (const li of list.querySelectorAll('li[data-id]')) {
const task = board.findById(Number(li.dataset.id));
if (task !== null) refresh(li, task);
}
paintSummary(summary, board, today);
}Compare with the 06-03 version: there was an addEventListener inside a loop there; here there is a single one, outside every loop. And the important thing is what does not have to change when in the next lesson the <li>s are created from JavaScript: nothing. The handler is already on the <ul>, and any button that appears inside it will reach it by bubbling.
- Custom events:
CustomEvent and detail
CustomEvent and detailUntil now every event came from the browser. But you can create and fire your own too, and that turns the DOM into a communication channel between the parts of your application.
// Create
const event = new CustomEvent('task:changed', {
detail: { id: 6, status: 'in-progress', title: 'Carpentry workshop quote' },
bubbles: true, // does it bubble? False by default
cancelable: true // can preventDefault() be called? False by default
});
// Fire it on an element (or on document/window)
li.dispatchEvent(event);
// Listen, on any ancestor if it bubbles
document.addEventListener('task:changed', (e) => {
console.log(`Task ${e.detail.id} moved to ${e.detail.status}`);
});Four important points:
detailis the only place your data goes. It is a read-only property and it can hold any value: an object, an array, a number.bubblesisfalseby default, the opposite of most native events. If you forget it, thedocumenthandler never finds out and you will spend a good while looking for the failure.dispatchEventis synchronous. It queues nothing: it runs the handlers immediately and hands control back when they finish. That is different from the behavior of user events, which do go through the task queue (05-07).- The naming convention
domain:action(task:changed,board:updated,filter:applied) avoids collisions with native events present and future, and makes it obvious in the code what is being talked about.
If the event is cancelable, dispatchEvent returns false when some handler called preventDefault(). That lets an event act as a request for permission:
const proceed = li.dispatchEvent(new CustomEvent('task:before-close', {
bubbles: true, cancelable: true, detail: { id: task.id }
}));
if (!proceed) {
console.log('Somebody has vetoed closing the task.');
return;
}
board.changeStatus(task.id, 'done');
- Decoupling the view from the model with events
This is the valuable use. Without custom events, every part of the interface that has to react to a change must be known by whoever causes it:
// ✗ The controller has to know everybody
board.changeStatus(id, destination);
refreshList();
refreshSummary();
refreshWorkloadChart();
refreshOverdueCounter();
// … and every new piece forces you to touch this functionWith events, whoever causes the change only announces that it has happened, and whoever is interested subscribes:
flowchart LR
U["Marta's click"] --> C["controller.js<br/>delegated on the ul"]
C --> M["model<br/>board.changeStatus()"]
M --> C
C -- "dispatchEvent<br/>task:changed" --> DOC["document"]
DOC --> V1["Task list<br/>repaints the li"]
DOC --> V2["Summary<br/>recomputes hours"]
DOC --> V3["Activity log<br/>adds a line"]
The controller does not know how many listeners there are or what they do. Adding a fourth does not force it to change a single line. In Nómada Tasks:
// js/view/events.js — centralized names, so we do not write loose strings
export const EVENTS = Object.freeze({
TASK_CHANGED: 'task:changed',
TASK_CREATED: 'task:created',
BOARD_UPDATED: 'board:updated',
FILTER_APPLIED:'filter:applied'
});
/** Fires an application event from an element (always bubbles). */
export function emit(source, type, detail = {}) {
return source.dispatchEvent(new CustomEvent(type, { bubbles: true, detail }));
}// js/view/activity-log.js — a new view that nobody had to "connect"
import { EVENTS } from './events.js';
export function connectActivityLog(container, { signal } = {}) {
document.addEventListener(EVENTS.TASK_CHANGED, (event) => {
const { title, status } = event.detail;
const line = document.createElement('li');
line.textContent = `${new Date().toLocaleTimeString('en-GB')} · ${title} → ${status}`;
container.prepend(line);
}, { signal });
}// js/app.js
import { Board } from './model/board.js';
import { createBacklog } from './data/backlog.js';
import { TODAY } from './util/dates.js';
import { connectBoard } from './view/controller.js';
import { connectActivityLog } from './view/activity-log.js';
import { EVENTS } from './view/events.js';
const board = new Board('Taller Nómada', createBacklog());
connectBoard({
list: document.querySelector('#task-list'),
summary: document.querySelector('#summary'),
board,
today: TODAY
});
connectActivityLog(document.querySelector('#activity-log'));
// A global listener for debugging during development
document.addEventListener(EVENTS.TASK_CHANGED, (e) => {
console.log('[event]', e.type, e.detail, '· effort now:', board.effort);
});A click on "Start" for the carpentry task now produces, in a cascade and without any piece knowing the others:
[event] task:changed { id: 6, status: 'in-progress', title: 'Carpentry workshop quote' } · effort now: 124And on the screen: the <li> repainted, the summary updated and a new line in the activity log.
When to use custom events and when not to. They are ideal for communicating upward or sideways (a component reports that something happened, without knowing who is listening). They are not appropriate for one-to-one communication where a direct function call is clearer: if the controller needs the immediate result of something, call it. And beware of overusing them: an application where everything communicates through events is hard to follow, because the trace stops reading top to bottom. A handful of well-named events is an architecture; thirty are a maze.
AbortController: removing many handlers at once
AbortController: removing many handlers at onceRemember the problem from 06-03: to remove a handler you need the exact reference of the function. With ten handlers scattered around, the cleanup becomes ten lines and ten variables.
AbortController solves it. It is an object with a signal property and an abort() method. If you pass that signal as an option to addEventListener, every handler registered with it is removed when abort() is called:
const controller = new AbortController();
const { signal } = controller;
list.addEventListener('click', onClick, { signal });
bar.addEventListener('click', onFilter, { signal });
document.addEventListener('keydown', onKeyDown, { signal });
window.addEventListener('resize', onResize, { signal });
// A single line removes all four:
controller.abort();This fits perfectly with the lesson's pattern: each connectX function accepts a signal and passes it along, so that tearing down the entire interface is trivial.
// js/app.js
const controller = new AbortController();
const { signal } = controller;
connectBoard({ list, summary, board, today: TODAY, signal });
connectActivityLog(document.querySelector('#activity-log'), { signal });
// For instance, when switching views or reloading the data:
// controller.abort(); ← all the interaction is disconnected in one goDetails worth knowing:
- An aborted
signalcannot be reused: you have to create a newAbortController. If you register a handler with an already aborted signal, it simply is not registered. signalandoncecombine without any problem:{ once: true, signal }.AbortControlleris not exclusive to events: it is the browser's standard cancellation mechanism, and you will use it again to cancel network requests in Robust Requests.
Common Mistakes and Tips
- Using
event.targetwhereclosest()was needed. If the button contains an icon,targetwill be the icon.event.target.closest('[data-action]')is the correct pattern, always. - Forgetting the guard
if (element === null) return;in a delegated handler. You receive every click in the container, including the ones going nowhere, and without the guard you get aTypeError. - Forgetting
bubbles: trueon aCustomEvent. The event fires, nothing fails and nobody hears it. It is the most baffling failure of the lesson, because there is no symptom. - Overusing
stopPropagation(). It breaks features that do not even exist yet and leaves no trace. Prefer a guard in the handler above. - Believing
preventDefault()stops bubbling. It does not, andstopPropagation()does not cancel the default action. They are independent axes: go back over the table in section 5. - Trying to delegate
focusorblur. They do not bubble. Usefocusin/focusout, or register with{ capture: true }. - Delegating on
documentwhen the container was enough. The closer the handler is to the target, the fewer irrelevant events it has to discard. Delegate on the<ul>, not ondocument. - Reusing an already aborted
AbortController. It does not work again. Create a new one every time you mount the interface. - Tip: name your events with
domain:action. It avoids collisions and documents the intent. And centralize the strings in anEVENTSmodule, as in section 11, so that a typo is a visibleundefinedand not an event nobody listens to. - Tip: use
getEventListeners($0)in the Chrome console. It shows every handler on the selected element; it is the best way of verifying that delegation did not leave duplicated handlers along the way.
Exercises
Exercise 1 · The phases experiment
Register six click handlers —capture and bubbling on section, ul and li— and click the button of a task. Predict the order in writing before running it, then check it. Afterwards add stopPropagation() in the <ul>'s capture handler and explain exactly which handlers stop running and why.
Exercise 2 · Delegation with two actions and a confirmation
Extend the delegated handler on #task-list so that it supports a third action, data-action="archive", which hides the <li> with hidden. Before archiving it must emit a cancelable task:before-archive event, and only carry on if nobody has vetoed it. Then write a listener that vetoes archiving any task that is not done. All of it with a single addEventListener on the <ul> (plus the veto listener).
Exercise 3 · Decoupled workload panel
Create js/view/workload.js with a function connectWorkload(container, board, { signal }) that draws the open hours per assignee (Iván 25, Lucía 14, Marta 6) and updates itself every time task:changed is emitted, without the board controller having to know this panel exists. Use AbortController in app.js so you can disconnect everything from the console.
Solutions
Exercise 1
for (const [name, elem] of [['section', section], ['ul', ul], ['li', li]]) {
elem.addEventListener('click', () => console.log(`capture · ${name}`), { capture: true });
elem.addEventListener('click', () => console.log(`bubble · ${name}`));
}Order when clicking the button:
First the complete descent (from the outside in), then the complete climb (from the inside out). The <li> appears in both lists because it has a handler in each phase; since the real target is the <button>, for the <li> both are ordinary journey phases.
With stopPropagation() in the <ul>'s capture handler, the output shrinks to:
Everything else is cut off: the event does not even reach the <li>, nor the <button> (which was the target), nor does it climb back up. It is the most forceful demonstration of why stopPropagation in capture is so destructive: it cancels the event for the whole subtree, including the element where the user actually clicked.
Exercise 2
import { emit, EVENTS } from './events.js';
list.addEventListener('click', (event) => {
const button = event.target.closest('button[data-action]');
if (button === null || !list.contains(button)) return;
const li = button.closest('li[data-id]');
const task = board.findById(Number(li.dataset.id));
if (task === null) return;
if (button.dataset.action === 'archive') {
const allowed = li.dispatchEvent(new CustomEvent('task:before-archive', {
bubbles: true, cancelable: true, detail: { id: task.id, status: task.status }
}));
if (!allowed) {
console.warn(`Archiving vetoed for "${task.title}" (${task.status}).`);
return;
}
li.hidden = true;
emit(list, EVENTS.BOARD_UPDATED, board.summary(TODAY));
return;
}
// … the 'advance' and 'reopen' actions, as in section 9
});
// The veto, in another module the controller knows nothing about
document.addEventListener('task:before-archive', (event) => {
if (event.detail.status !== 'done') event.preventDefault();
});Two key points. The first: dispatchEvent returns false if some handler called preventDefault(), and that only works if the event was created with cancelable: true; without that option, preventDefault() is ignored and dispatchEvent always returns true. The second: the controller does not contain the rule "only what is done can be archived". That rule lives in another module, and could be changed or withdrawn without touching the controller. It is exactly the decoupling we are after.
Exercise 3
// js/view/workload.js
import { EVENTS } from './events.js';
export function connectWorkload(container, board, { signal } = {}) {
function paint() {
const hours = board.hoursByAssignee(); // { Iván: 25, Lucía: 14, Marta: 6 }
const total = Object.values(hours).reduce((s, h) => s + h, 0);
container.replaceChildren(); // empty it without innerHTML
for (const [who, h] of Object.entries(hours).sort((a, b) => b[1] - a[1])) {
const row = document.createElement('li');
row.textContent = `${who}: ${h} h (${Math.round((h / total) * 100)} %)`;
row.style.setProperty('--percentage', `${(h / total) * 100}%`);
container.append(row);
}
}
paint();
document.addEventListener(EVENTS.TASK_CHANGED, paint, { signal });
}// js/app.js
const controller = new AbortController();
const { signal } = controller;
connectBoard({ list, summary, board, today: TODAY, signal });
connectWorkload(document.querySelector('#workload'), board, { signal });
globalThis.disconnect = () => controller.abort(); // to try it out from the consoleThe panel starts with Iván: 25 h (56 %), Lucía: 14 h (31 %), Marta: 6 h (13 %), adding up to the canonical 45 open h. When the carpentry task is marked as done, Iván drops to 20 h and all the percentages recompute by themselves, without controller.js mentioning this module even once: the communication goes entirely through the task:changed event. And by running disconnect() in the console, the abort() removes both the board's delegated handler and the workload panel's in one go. The createElement, append and replaceChildren methods appearing here are exactly the topic of the next lesson.
Conclusion
You now know how an event travels and how to take advantage of it. The journey has three phases —capture from the outside in, target, and bubbling from the inside out—, addEventListener registers in bubbling unless you ask for { capture: true }, and throughout the trip target does not change (it is where the event happened) while currentTarget is different in each handler. You can interrupt the journey with stopPropagation() and, more forcefully, with stopImmediatePropagation(), which also cancels the other handlers on the same element; and you know that neither of the two is the same as preventDefault(), which cancels the browser's default action and does not touch propagation. You also know that stopPropagation is a decision with global consequences and that a guard in the handler above is almost always preferable. You know the events that do not bubble —focus, blur, load, error, mouseenter— and their alternatives: focusin/focusout or the capture phase.
On that basis you have the highest-value pattern of the module: delegation. A single handler on ul#task-list that attends to the clicks of every button of every task, with the canonical four-step shape: event.target.closest('[data-action]'), a guard against null, closest('li[data-id]') to know which task it is about, and Number(li.dataset.id) to cross the boundary into the model. It works with elements that do not exist yet —which is the main reason for using it and what will keep the next lesson from breaking anything—, consumes a fraction of the memory and concentrates all the interaction logic in one place.
And you have the mechanism for the pieces of the application to talk to each other without knowing each other: custom events. new CustomEvent('task:changed', { bubbles: true, detail }) and dispatchEvent, with the domain:action convention, the reminder that bubbles is false by default and that firing is synchronous, and the option of creating cancelable events that work as a request for permission. With them, the board controller limits itself to announcing what has happened, and the summary, the activity log and the workload panel per assignee —25 h for Iván, 14 for Lucía, 6 for Marta— update by themselves. AbortController rounds off the set: a shared signal that lets you remove at a stroke every handler registered with it, the same cancellation mechanism that will reappear with network requests in Module 7.
One big and very visible gap remains: the list still has a few <li>s written by hand in the HTML, while the backlog has six tasks and in 06-07 you will be able to add more. The page cannot go on being a fixed mold that we fill in; it has to be built from the model. How an element is created from scratch, how it is inserted in exactly the right place, how it is removed without leaving memory leaks, how a hundred nodes are inserted without punishing the browser and how a template declared in the HTML is cloned is Creating and Removing DOM Elements, where you will write view/card.js and finally turn a model Task into its complete <li>.
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
