RizTech Academy logo
RizTech Academy
Design Patterns in JavaScriptLesson 5 of 630 min

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

  1. Open your capstone's state.js and find the subscribe, the notify and the unsubscribe.
  2. Subscribe the same named function twice and confirm it is notified once.
  3. Subscribe two identical arrow functions and confirm it is notified twice. Explain.
  4. Make one listener throw and confirm the others still run and the order is still added.
  5. Remove the try/catch and repeat. Decide which behaviour you want.
  6. Have a listener unsubscribe itself inside its own callback. Then remove the [...listeners] copy and try again.
  7. Move the notify above the state change and write a listener that reads the state.
  8. Register three listeners with one AbortController signal and remove them all with one call.
  9. Call removeEventListener with a fresh arrow function and confirm nothing is removed.
  10. Take the six-line addOrder from 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