Observer, and the event system you already use
The browser is an observer system. Every click handler you have written is this pattern, which makes it the one you already understand and the one worth learning to build yourself.
The problem
function addOrder(order) {
orders.push(order);
renderList();
updateTotal();
saveToStorage();
showToast("Order added");
refreshBadge();
}
Adding an order now means knowing about rendering, totals, storage, toasts and a badge. A sixth thing means editing this function. Testing it means providing five collaborators to test one line.
The pattern
The thing that knows something happened publishes it. The things that care subscribe. Neither knows the other.
Your capstone already does this — here it is with the reasoning:
const listeners = new Set();
export function subscribe(listener) {
listeners.add(listener);
// Hand back the way to undo it. A subscribe with no unsubscribe is a
// memory leak waiting to be written.
return () => listeners.delete(listener);
}
function changed() {
save(STORAGE_KEY, orders);
// Iterate a copy. A listener that unsubscribes itself while we are
// notifying would otherwise mutate the Set mid-iteration.
for (const listener of [...listeners]) {
try {
listener();
} catch (error) {
// One broken listener must not stop the others, and must not undo the
// change. It has already happened; this is notification.
console.error("listener failed", error);
}
}
}
export function addOrder(order) {
orders.push(order);
changed();
}
subscribe(renderList);
subscribe(updateTotal);
subscribe(refreshBadge);
addOrder now knows about an array and a set of listeners. It has never heard
of a badge.
A Set, not an array
const listeners = new Set();
Two reasons. Subscribing the same function twice adds it once, so a component
that re-runs its setup does not get notified twice. And delete is O(1) rather
than an indexOf scan.
The catch: Set uses identity, so this does not unsubscribe anything:
subscribe(() => render());
// later
listeners.delete(() => render()); // a different function object
Which is exactly why subscribe returns the remover instead of expecting you
to keep the reference.
The four details that matter
Most observer bugs are one of these.
Iterate a copy. [...listeners] before the loop. A listener that
unsubscribes itself during notification — very common, because "tell me once
then stop" is a normal thing to want — otherwise mutates the collection being
iterated.
A failing listener must not fail the publisher. The order is already added. If the toast code throws, that is a toast problem. Catch, log, continue.
Return the unsubscribe. A listener never removed keeps its closure — and everything that closure captured — alive forever. In a single-page application that is the classic memory leak: navigate between views a hundred times and you have a hundred live listeners rendering into detached DOM.
Publish after the change. orders.push(order) then changed(). Notifying
before the state has changed means listeners read the old value.
Order is not a contract
It is tempting to rely on listener order — "the storage listener runs before the render listener, so render can read what was saved".
Do not. The moment two listeners depend on each other's effects you have a sequence dressed up as a broadcast, and it breaks when somebody reorders two lines of setup. If A must happen before B, that is one listener doing both, or a function — not two observers and a hope.
The browser's version
button.addEventListener("click", onClick);
button.removeEventListener("click", onClick); // needs the same reference
Same pattern, same gotcha — removeEventListener with a fresh anonymous
function removes nothing, because it is a different object. Module 7 covered
that; it is the same identity problem as the Set.
The browser also gives you AbortController, which is the neatest way to
unsubscribe several at once:
const controller = new AbortController();
const { signal } = controller;
button.addEventListener("click", onClick, { signal });
input.addEventListener("input", onInput, { signal });
window.addEventListener("resize", onResize, { signal });
controller.abort(); // removes all three
That is worth knowing: one object, one call, every listener gone. It is the
cleanest teardown JavaScript has ever had, and it works for fetch too.
Custom events
You can publish through the DOM rather than your own Set:
document.dispatchEvent(new CustomEvent("order:added", { detail: order }));
document.addEventListener("order:added", (e) => render(e.detail));
The browser handles the bookkeeping, and events bubble so a parent can listen
for something a child dispatched. The cost is that everything is stringly typed
and the data hides in detail.
Use your own Set for application state — it is direct, typed as well as
JavaScript types anything, and debuggable. Use custom events when the
publisher and subscriber genuinely cannot import each other, which mostly
means web components and scripts you do not control.
What it costs
Observer buys decoupling and charges in traceability.
With a direct call you read the function and see everything that happens. With
observers you read the function and see changed() — and finding what actually
happens means searching for every subscribe call. Debugging becomes "set a
breakpoint and see who turns up".
So: use it when the publisher genuinely should not know its audience. Do not use it for three things that always happen in the same order. That is a function, and a function you can read.
Check your work
What the pattern decouples: the thing that knows from the things that care.
Why a Set: duplicate subscription is idempotent, and removal is O(1).
Why subscribe returns a remover: identity means you cannot delete a
listener you did not keep a reference to.
Why iterate a copy: a listener unsubscribing itself would mutate the collection mid-iteration.
Why catch a listener's error: the change already happened, and notification failing must not undo it.
Why the unsubscribe matters: a listener never removed keeps its closure alive — the classic single-page-app leak.
Why publish after: listeners would otherwise read the old state.
Why not to rely on order: it turns a broadcast into a hidden sequence.
What AbortController gives you: one call removing every listener
registered with its signal.
The cost: traceability — reading the publisher no longer tells you what happens.
Practice
- Open your capstone's
state.jsand find the subscribe, the notify and the unsubscribe. - Subscribe the same named function twice and confirm it is notified once.
- Subscribe two identical arrow functions and confirm it is notified twice. Explain.
- Make one listener throw and confirm the others still run and the order is still added.
- Remove the
try/catchand repeat. Decide which behaviour you want. - Have a listener unsubscribe itself inside its own callback. Then remove the
[...listeners]copy and try again. - Move the notify above the state change and write a listener that reads the state.
- Register three listeners with one
AbortControllersignal and remove them all with one call. - Call
removeEventListenerwith a fresh arrow function and confirm nothing is removed. - Take the six-line
addOrderfrom the top of this lesson and argue it should stay exactly as it is.
Next: spotting all of these in code you did not write.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship