completefrontend
Open lab My progress
THE JAVASCRIPT FIELD GUIDE

Observer & pub/sub

Learn observer, pub/sub, event emitters, signals, and observables to let JavaScript app parts react without tight coupling.

By the end, you can
  • 01
    Let parts of an app react to a changeReplace one function that calls every job by name with listeners that sign up to be told.
  • 02
    Pick the right toolTell apart observer, pub/sub, browser events, Node events, signals, and observables.
  • 03
    Avoid common problemsKeep listeners in order, stop one error from breaking the rest, remove listeners when done, and keep the flow easy to follow.

Let many parts of an app react to one change

Apps often need several things to happen after one change. Say a shopper adds a book to the cart. Then:

  • the header shows the new number of items,
  • analytics, the code that records what shoppers do, notes the book,
  • storage saves the cart, so it is still there after a page reload.

The easy way is to make the cart code do all three jobs itself. That works. But then the cart code has to know about every job. This lesson shows better ways.

All of them use the same idea. One piece of code says "something changed." Other pieces of code ask to be told. Those are called listeners. A listener is just a function that waits to be called. Code also calls listeners observers, subscribers, or handlers. In this lesson, these four words mean the same thing.

The main idea
  • Observer pattern: one object, called the subject, keeps a list of listeners. When it changes, it calls each one. Calling them all is called notifying.
  • Publish/subscribe, or pub/sub: a middle object, called a bus, keeps the lists. Each list has a name. The code that sends a message only gives the bus a name and some data.
  • Event emitters, signals, and observables are other forms of the same idea. Each one gets its own section below.

The table below puts the five tools side by side. Read each row as: "this tool works like this, and here is a tiny example." Some words are new; each one is explained in its own section.

Five ways to let code react to a change
ToolHow it worksA tiny example
ObserverOne object, the subject, keeps a list of listeners. It calls them when it changes.A cart calls its header, analytics, and storage listeners, in the order they joined.
Pub/subA bus keeps a list of listeners for each topic name. The publisher only knows the topic name.bus.publish("cart:changed", payload) reaches every listener on that topic.
EventTargetThe browser's built-in event system. Listeners get an Event or CustomEvent object.target.dispatchEvent(new CustomEvent(...)) works with once and AbortController.
SignalsA value that remembers who read it. When it changes, only that code runs again.total = computed(() => price() * quantity()) updates when either signal changes.
ObservablesA stream that sends values one after another, over time.RxJS streams, or Chrome's when() method on an EventTarget.

This lesson uses ideas from earlier lessons: callbacks, closures, events, and custom events. The functors & monads lesson built a tiny observable. The memory management lesson explains why forgotten listeners waste memory.

The problem: a cart that knows too much

PROBLEM FIRST

Two pieces of code are tightly coupled when one has to know the other by name and call it directly. Here is a cart update that is tightly coupled to three other jobs.

A cart update that does every job itselfPop out in the code editor (opens in a new tab)JavaScript
const cart = []; function updateCart(item) {  cart.push(item);  renderHeader(cart.length);  trackAnalytics(item);  saveCart(cart);} function renderHeader(count) {  console.log(`header ${count}`);}function trackAnalytics(item) {  console.log(`analytics ${item}`);}function saveCart(items) {  console.log(`saved ${items.join(",")}`);} updateCart("book");

Line 4 adds the item to the cart. Line 5 updates the header. Line 6 tells analytics. Line 7 saves the cart. Line 20 calls updateCart("book"). The console shows header 1, analytics book, and saved book.

The updateCart function card has three arrows. They go to three other function cards: renderHeader, trackAnalytics, and saveCart. updateCart knows all three by name.updateCart()knows all threerenderHeader()trackAnalytics()saveCart()
updateCart() calls all three jobs by name. To add a fourth job, you must change updateCart() again.

So updateCart has four jobs, not one. Each new job means changing it again. And to test the cart, you also need the header, analytics, and storage code.

The goal

We want the cart code to say only "the cart changed." Each job should sign up for that news from its own file. Then the cart doesn't need to know which jobs exist, today or next year.

Observer: the subject keeps a list of listeners

STEP THROUGH

In the observer pattern, the subject has a subscribe function. To subscribe means to join the list. You pass in a listener function, and the subject adds it to its list.

subscribe gives back another function. Call that one later to leave the list. Leaving the list is called unsubscribing.

The code below keeps the list in a Set. A Set is a collection that holds each value only once. It also remembers the order you added them in.

Join the list, call everyone, then leavePop out in the code editor (opens in a new tab)JavaScript
const listeners = new Set();function subscribe(listener) {  listeners.add(listener);  return () => listeners.delete(listener);}const stopHeader = subscribe((item) => console.log(`header ${item}`));subscribe((item) => console.log(`storage ${item}`));for (const listener of [...listeners]) listener("tea");stopHeader();console.log(listeners.size);
  • Lines 2 to 5 make subscribe. Line 3 adds the listener to the set. Line 4 returns a small function that deletes that same listener.
  • Line 6 subscribes the header listener. It keeps the leave function in stopHeader. Line 7 subscribes the storage listener.
  • Line 8 calls every listener with "tea". The header joined first, so it prints header tea first. Then storage prints storage tea.
  • Line 9 calls stopHeader(), which removes the header. Line 10 prints 1, because only storage is left.
The value tea goes into a cart called listeners. The cart holds two functions: header first, storage second. The console then shows header tea and storage tea."tea"the value1st2ndheaderstoragelistenersConsoleheader teastorage tea
The listeners set holds two functions. Line 8 gives "tea" to each one, in the order they joined: header first, then storage.

On line 8, [...listeners] copies the set into a new array before the loop starts. You'll see why that copy matters in a moment.

Real-life analogyA newsletter sign-up list

A newsletter keeps a list of readers. Readers don't change the newsletter. They only sign up or cancel. When a new issue is ready, the newsletter sends it to everyone on the list that day.

In real life: A reader signs up
In JavaScript: subscribe(listener) adds the function to the list
In real life: The newsletter sends a new issue
In JavaScript: The subject calls each listener with the new value
In real life: A reader cancels
In JavaScript: Calling the function that subscribe returned removes the listener

A subject in a real app needs two safety steps:

  • Copy the list before calling anyone, with [...observers]. Then a listener that leaves halfway through doesn't change the loop that is running.
  • Catch an error from one listener, so the next listeners still run. try/catch does this. If code inside try throws an error, JavaScript jumps to catch instead of stopping.

Step through the example below. Watch the storage listener remove itself during the first call to notify.

The order in which listeners run
Step 0 of 15Ready
Your turn: follow the blue line

Step through the order in which listeners run. Watch the copied list, the caught error, and how storage leaving changes only the next notify.

Running in
  1. script
Next: line 23
function createSubject(name) {  const observers = new Set();  return {    subscribe(observer) {      observers.add(observer);      return () => observers.delete(observer);    },    notify(value) {      for (const observer of [...observers]) {        try {          observer(value);        } catch (error) {          console.log(`${name} isolated ${error.message}`);        }      }    },    count() {      return observers.size;    },  };} let stopStorage;cart.subscribe((item) => console.log(`header ${item}`));cart.subscribe(() => { throw new Error("analytics offline"); });stopStorage = cart.subscribe((item) => {  console.log(`storage ${item}`);  stopStorage();});cart.notify("book");cart.notify("pen");console.log(`subscribers ${cart.count()}`);
CallStoreChangeResultBlue line: runs next. Ran: already executed.
Recent returnsNothing yet. Start with the blue line.
A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.

Here is the order. The header runs. Analytics throws an error, and the catch prints it. Storage still runs, and then removes itself. The second notify reaches only the header and analytics. This works because of the copied list and the try/catch around each listener.

Next, one listener removes a different listener while the subject is calling everyone.

One listener removes another during a callPop out in the code editor (opens in a new tab)JavaScript
const listeners = new Set();function subscribe(listener) {  listeners.add(listener);  return () => listeners.delete(listener);}let stopStorage = () => {};subscribe((item) => {  console.log(`header ${item}`);  if (item === "first") stopStorage();});stopStorage = subscribe((item) => console.log(`storage ${item}`));function notify(value) {  for (const listener of [...listeners]) listener(value);}notify("first");notify("second");console.log(listeners.size);

Line 13 copies the set before it calls anyone. On the first call, line 15, the header runs first. Line 9 then removes storage from the set. But the copy already has storage in it, so storage still prints storage first.

On the second call, line 16, a new copy is made without storage. So only header second prints. Line 17 prints 1.

Left: notify first makes copy 1, which holds header and storage, so both run. Right: notify second makes copy 2, which holds only header, so only header runs.notify("first")headerstoragecopy 1both runnotify("second")headercopy 2only header runs
Each notify() copies the set first. The header removes storage during the first call, but copy 1 already has storage in it. Copy 2 does not.
One listener fails, and the rest still runPop out in the code editor (opens in a new tab)JavaScript
const observers = [  () => console.log("header updated"),  () => { throw new Error("offline"); },  () => console.log("storage saved"),];for (const observer of [...observers]) {  try {    observer();  } catch (error) {    console.log(`reported ${error.message}`);  }}

Line 3 is a listener that throws an error with the message offline. The try/catch on lines 7 to 11 sits inside the loop, around each single call. So line 10 prints reported offline, and the loop moves on. The last listener still prints storage saved.

Four rules for calling listeners
  • A simple loop like this calls listeners right away, one after another, in the order they joined.
  • If a listener is removed while the loop runs over a copy, it still runs this time, but not next time.
  • try/catch can't catch an error from an async listener. An async function returns a promise, and its error arrives later, after the loop has moved on.
  • A Set holds the same function only once. But two arrow functions that look alike are two different functions, so the set keeps both.

Publish/subscribe: a bus in the middle

TOPICS

Pub/sub goes one step further than observer. The code that announces a change is called the publisher. In pub/sub, the publisher doesn't keep a list at all. A third object, the bus, keeps the lists. (Some code calls the bus a broker.)

  • Each list in the bus has a name, called a topic, such as cart:changed.
  • Listeners subscribe to a topic.
  • The publisher gives the bus a topic and some data. That data is called the payload.
Real-life analogyRadio channels

A radio station plays music on one channel. People tune their radios to the channel they want. The station has no list of who is listening. It just plays.

In real life: The station plays on one channel
In JavaScript: The publisher calls publish(topic, payload)
In real life: People tune in to the channel they want
In JavaScript: Listeners subscribe to a topic
In real life: The station doesn't know who is listening
In JavaScript: The publisher doesn't know who subscribed
The same message, sent by a subject and by a busPop out in the code editor (opens in a new tab)JavaScript
const cart = { observers: new Set() };cart.observers.add((item) => console.log(`observer ${item}`));for (const observer of cart.observers) observer("tea"); const broker = new Map();broker.set("cart:changed", [(item) => console.log(`pub/sub ${item}`)]);for (const handler of broker.get("cart:changed")) handler("tea");

Lines 1 to 3 are the observer way. The list lives inside cart, so the cart knows its listeners.

Lines 5 to 7 are the pub/sub way. broker is a Map, a collection of named entries. The list lives in it under the name cart:changed. Code only needs that name to reach the listeners. Both ways print their label with tea.

The bus has a cost. After a publish, it is harder to find which code runs, because the listeners live somewhere else.

A tiny bus with topicsPop out in the code editor (opens in a new tab)JavaScript
function createBus() {  const topics = new Map();  return {    subscribe(topic, handler) {      const handlers = topics.get(topic) ?? new Set();      handlers.add(handler);      topics.set(topic, handlers);      return () => handlers.delete(handler);    },    publish(topic, payload) {      for (const handler of topics.get(topic) ?? []) {        handler(payload);      }    },  };} const bus = createBus();bus.subscribe("cart:changed", ({ count }) => console.log(`header ${count}`));bus.subscribe("analytics:cart", ({ sku }) => console.log(`analytics ${sku}`));bus.publish("cart:changed", { count: 2, sku: "book" });bus.publish("analytics:cart", { count: 2, sku: "book" });
  • Line 4 is subscribe. It adds a listener to the list for one topic.
  • Line 10 is publish. It calls only the listeners for that exact topic.
  • Line 19 subscribes the header to cart:changed. Line 20 subscribes analytics to analytics:cart.
  • Line 21 publishes on cart:changed. Only the header runs, and it prints header 2.
  • Line 22 publishes on analytics:cart. Only analytics runs, and it prints analytics book.
A publish card points at the topic tag cart:changed, which points at the header function. The header prints header 2. A second tag, analytics:cart, points at the analytics function, which is not called.topics in the buspublish()line 21cart:changedheaderheader 2analytics:cartanalyticsnot called
Line 21 publishes on cart:changed. The bus looks up that one topic, so only the header function runs and prints header 2.

Give topics clear names that start with the area they belong to, like cart:changed, analytics:cart, or cart:item:removed. Then you can search your code for them. Some buses let one listener subscribe to many topics with a pattern such as cart:*. Use that with care: it makes it harder to see which code runs.

When pub/sub helps, and when it hurts

Pub/sub works well when parts of an app should not import each other. Examples are plugins, analytics, and messages between separate parts of a page. It is also easy to overuse. A long chain of hidden events is hard to debug. And a listener you forget to remove can cause a memory leak: memory that is never freed.

Event emitters in the browser and in Node

PLATFORM APIs

An event emitter is an object that sends named events to its listeners. You don't have to build one yourself. The browser and Node each come with one.

In the browser, it is EventTarget. Every element on a page is an EventTarget. That is how clicks reach your listeners. You can also make a plain one with new EventTarget().

To send your own event, make a CustomEvent and put the data in its detail option. The custom events lesson shows more.

To remove many listeners at once, use an AbortController. Pass its signal when you add each listener. Later, one call to abort() removes all of them.

Browser events with CustomEvent and AbortControllerPop out in the code editor (opens in a new tab)JavaScript
const target = new EventTarget();const group = new AbortController(); target.addEventListener("cart:changed", (event) => {  console.log(`header ${event.detail.count}`);}, { signal: group.signal }); target.addEventListener("cart:changed", (event) => {  console.log(`analytics ${event.detail.sku}`);}, { once: true }); target.dispatchEvent(new CustomEvent("cart:changed", {  detail: { count: 1, sku: "book" },}));group.abort();target.dispatchEvent(new CustomEvent("cart:changed", {  detail: { count: 2, sku: "pen" },}));
  • Lines 4 to 6 add a header listener tied to group.signal.
  • Lines 8 to 10 add an analytics listener with { once: true }. That means "remove me after I run once."
  • Line 12 sends the first event. Both listeners run, so the console shows header 1 and analytics book. The analytics listener then removes itself.
  • Line 15 calls group.abort(), which removes the header listener.
  • So the second event, on line 16, prints nothing. No listeners are left.

dispatchEvent() calls every listener right away, before it returns. It doesn't wait.

Node's version is called EventEmitter. Its method names are different:

  • on adds a listener.
  • once adds a listener that runs only once.
  • off removes a listener.
  • emit sends an event.

Two more things to know. First, an event named error is special. If you emit it and no listener handles it, Node throws an error. Second, Node prints a warning when you add too many listeners to one event. That warning often means some code forgot to remove its listeners.

Node EventEmitter: on, once, off, emit, and two special casesJavaScript
import { EventEmitter } from "node:events"; const bus = new EventEmitter();function header({ count }) {  console.log(`header ${count}`);}function storage({ count }) {  console.log(`storage ${count}`);} bus.on("cart:changed", header);bus.once("cart:changed", ({ count }) => console.log(`analytics first ${count}`));bus.on("cart:changed", storage);bus.emit("cart:changed", { count: 1 });bus.off("cart:changed", storage);bus.emit("cart:changed", { count: 2 }); const errors = new EventEmitter();errors.on("error", (error) => console.log(`handled ${error.message}`));errors.emit("error", new Error("offline")); const noisy = new EventEmitter();noisy.setMaxListeners(1);noisy.on("warn", () => {});noisy.on("warn", () => {});console.log(`listeners ${noisy.listenerCount("warn")}`);
  • Line 14 emits the first event. The header, the once-only analytics listener, and storage all run.
  • Line 15 removes storage, so line 16 reaches only the header.
  • Line 20 emits error. The listener on line 19 handles it, so nothing throws.
  • Line 23 sets the limit to one listener, and lines 24 and 25 add two. So Node prints a warning, and line 26 prints listeners 2.
Why does this code have no Run button?

node:events is part of Node, not the browser, so this page can't run it. In Node, it prints header 1, analytics first 1, storage 1, header 2, handled offline, and listeners 2. Node also prints a MaxListenersExceededWarning.

Signals: a value that knows who uses it

REAL RUNTIME

A signal holds one current value. You call it to read the value, like price(). You change it with .set(). The clever part: a signal remembers which code read it. When the value changes, only that code runs again.

Two more words come with signals:

  • A computed value is worked out from other signals. For example, total is price() times quantity().
  • An effect is code that runs again whenever a signal it read changes. For example, code that prints the total.

So when quantity changes, only total and the code that prints it run again. Nothing else in the app does.

The tiny version below is real, working JavaScript. Here is how it knows who read a signal:

  • Before an effect runs, it puts itself on top of a list called effectStack.
  • Any signal that is read while the effect runs looks at the top of that list. It adds that effect to its own subscribers.
  • When the effect finishes, it takes itself off the list.

The code also does two tidy-up jobs. It clears an effect's old subscriptions before the effect runs again. And it copies a signal's subscriber list before calling it. Real signal libraries add more, such as grouping many updates into one, but the main idea is all here.

Tiny signals, step by step
Step 0 of 10Ready
Your turn: follow the blue line

This tiny signals code uses an effect stack to see who reads each signal. Step through the first calculation, then the change that makes total and the printing effect run again.

Running in
  1. script
Next: line 49
const effectStack = []; function cleanup(runner) {  for (const dependency of runner.dependencies) {    dependency.delete(runner);  }  runner.dependencies.clear();} function effect(work) {  const runner = () => {    cleanup(runner);    effectStack.push(runner);    try {      work();    } finally {      effectStack.pop();    }  };  runner.dependencies = new Set();  runner();  return () => cleanup(runner);} function signal(value) {  const subscribers = new Set();  const read = () => {    const runner = effectStack[effectStack.length - 1];    if (runner) {      subscribers.add(runner);      runner.dependencies.add(subscribers);    }    return value;  };  read.set = (next) => {    if (Object.is(next, value)) return;    value = next;    for (const runner of [...subscribers]) runner();  };  return read;} function computed(work) {  const output = signal();  effect(() => output.set(work()));  return output;} const quantity = signal(2);const total = computed(() => price() * quantity());effect(() => console.log(`total ${total()}`));quantity.set(3);
CallStoreChangeResultBlue line: runs next. Ran: already executed.
Recent returnsNothing yet. Start with the blue line.
A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.

You'll find signals in Solid, Preact Signals, and Angular. Vue's ref works in a similar way. There is a proposal to add signals to JavaScript itself, but it is only at Stage 1, an early step. So today, each framework gives you its own signals.

Observables: values that arrive over time

STREAMS

An observable is a stream: a source that sends values one after another, over time. Clicks, messages from a server, timer ticks, and a file arriving in pieces are all streams.

A listener gets each value through a function called next. It keeps getting values until it unsubscribes or the stream ends. RxJS is the most common library for observables.

The functors & monads lesson built a tiny observable. This lesson won't build one again. A later project lesson builds a full event emitter from scratch. Here, just learn the difference:

  • A signal answers "what is the value now?"
  • An observable answers "what values will arrive over time?"
Does this browser have built-in observables?
Check for the built-in Observable APIPop out in the code editor (opens in a new tab)JavaScript
console.log(typeof EventTarget.prototype.when);
Resulttypeof when

checking

Try it yourself
Checked in this browser

The check runs as soon as the page has loaded.

The code asks what type when is. In Chrome it prints function. If you need observables in every browser, use the RxJS library.

Browsers are adding observables too, through a proposal called the Observable API. In Chrome, every EventTarget already has a when() method that gives you an observable of its events. Other browsers may not have it yet. So check first, as the code above does. Checking whether a browser has a feature before you use it is called feature detection.

If you need observables in every browser today, use RxJS or your framework's tools.

Choose the right tool

PLAYGROUND

Pick the smallest tool that fits. Start by asking who should own the list of listeners:

  • If one object owns the data, use observer.
  • If separate parts of the app should talk without importing each other, use pub/sub. The cost is that the flow is harder to follow.
  • If you are in the browser or in Node, use the event emitter that comes with it.
  • If you need values worked out from other values, use signals.

Read each row of the table as: "in this situation, this tool fits, for this reason."

Which tool fits which situation
SituationTool that fitsWhy
One object owns the data, and listeners can know about itObserverThe subject can offer subscribe, call each listener, and give back an unsubscribe function.
Parts of the app should talk without importing each otherPub/subTopics keep the publisher and the listeners apart. The cost: the flow is harder to follow.
Browser pages and page elementsEventTargetIt is built in. Use CustomEvent, once, and AbortController.
A value worked out from other values on the pageSignalsOnly the code that read the value runs again.
Values that keep arriving, and that you may want to stopObservableA stream sends values over time. You can change them with steps like map, filter, and take.

A listener can also keep other things in memory. Say a listener uses a profile name or a page element. While the listener is in the list, that name or element stays in memory too, even after the profile is closed. So remove listeners when you are done with them.

The small example below counts listeners. It doesn't measure memory.

Remove a profile's listener when the profile closesPop out in the code editor (opens in a new tab)JavaScript
const listeners = new Set();function mountProfile(name) {  const listener = () => console.log(name);  listeners.add(listener);  return () => listeners.delete(listener);}const closeProfile = mountProfile("Asha");console.log(listeners.size);closeProfile();console.log(listeners.size);
  • Line 3 makes a listener that uses name.
  • Line 4 adds it to the set, so line 8 prints 1.
  • Line 9 calls closeProfile(). That is the function mountProfile returned, and it removes the listener. So line 10 prints 0.

In a real page, call this cleanup function when the profile is taken off the page. JavaScript then frees the memory later, at a time it chooses. You can't make it free memory right now.

Turn listeners on and off
A store that keeps listeners in a setPop out in the code editor (opens in a new tab)JavaScript
const subscribers = new Set();const store = {  count: 0,  subscribe(widget) {    subscribers.add(widget);    return () => subscribers.delete(widget);  },  publish(nextCount) {    this.count = nextCount;    for (const widget of [...subscribers]) {      widget(this.count);    }  },};
What happenscart count 1
Storecount = 1
Header badgelistening
Analyticsnot listening
Storagelistening
What just happened
  1. The store is ready. Header and storage are listening.
Try it yourself

When you send a count, the store copies its set and calls 2 listeners.

The panel on the right shows what the store code on the left does. Add or remove a listener, then send a count. Only the listeners in the set react.
Observer, pub/sub, emitter, or signal?
  • `cart.subscribe(renderHeader); cart.notify(item);`
  • `bus.publish("cart:changed", payload);`
  • `target.dispatchEvent(new CustomEvent("cart:changed"));`
  • `emitter.once("ready", handler); emitter.emit("ready");`
  • `const total = computed(() => price() * quantity());`
  • `buttonClicks.map(toPoint).subscribe(draw);`
Try it yourself
0 of 6 correct

Sort each line of code by the tool it uses. Observables share a group with signals here, because both are values that make other code run again.

Choose a category for every card. You can change an answer at any time; Reset clears them all.

Common misconceptions

  • "Every callback is an observer." A callback is just a function you pass to other code. The observer pattern adds a subject that keeps a list of callbacks and calls them each time something changes.
  • "Observer and pub/sub are the same." They are close. But pub/sub adds a bus and topic names, so the publisher never knows who is listening.
  • "Events always run later." Many don't. dispatchEvent and most simple emitters call every listener right away, before the next line runs.
  • "Catching errors hides bugs." Catching an error and then ignoring it hides bugs. Catching one listener's error and reporting it keeps the other listeners working.
  • "Signals and observables are the same thing." A signal answers "what is the value now?" An observable answers "what values arrive over time?"
  • "A too-many-listeners warning means: raise the limit." Usually it means some code forgot to remove its listeners. Look for the missing cleanup first.

The next table lists four common problems. Read each row as: "this can go wrong, this is what happens, and this prevents it."

Common problems and how to prevent them
What can go wrongWhat happensHow to prevent it
The flow is hiddenListeners live in other files, so one publish can start work you didn't expect.Give topics clear names, and keep a list of all topics in one place.
A listener is never removedA part of the page is gone, but its listener still runs and keeps memory in use.Give back an unsubscribe function and call it. Use AbortController to remove a group at once.
One listener throws an errorThe error can stop the loop, so later listeners never get the update.Wrap each call in try/catch, report the error, and keep going through the copied list.
One event is too broadEvery listener gets every message and has to check if it cares.Use narrower topics, or a signal when only one value changed.

The last table compares the event tools with the value tools. Read across a row to see how each group answers the same question.

Event tools compared with value tools
QuestionObserver and pub/subSignals and observables
Who keeps the list of listeners?With observer, the subject keeps it. With pub/sub, the bus keeps it.Each signal keeps a list of the effects that read it. Each observable subscription keeps its own clean-up step.
What does a listener get?Some data about a thing that just happened.A signal gives the value now. An observable sends one value after another.
What is hard to debug?Hidden chains of events, and patterns like cart:* that match many topics.An effect that still listens to an old signal, a missing clean-up, or stream steps far from where you subscribe.

Practice exercises

5 EXERCISES
Exercise 1 · Warm-upPredict the order of listeners

Read the code. Type the two console lines, with a comma between them.

Starter codePop out in the code editor (opens in a new tab)JavaScript
const observers = [];
function subscribe(observer) {
  observers.push(observer);
  return () => observers.splice(observers.indexOf(observer), 1);
}
subscribe((value) => console.log("header " + value));
subscribe((value) => console.log("storage " + value));
for (const observer of [...observers]) observer("ready");

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · PracticeFollow one pub/sub topic

    Which one line prints when the bus emits cart:changed?

    Starter codePop out in the code editor (opens in a new tab)JavaScript
    const topics = new Map();
    function on(topic, handler) {
      topics.set(topic, [...(topics.get(topic) ?? []), handler]);
    }
    function emit(topic, value) {
      for (const handler of topics.get(topic) ?? []) handler(value);
    }
    on("cart:changed", (count) => console.log("header " + count));
    on("analytics:cart", (count) => console.log("analytics " + count));
    emit("cart:changed", 2);

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeRead an EventTarget once listener

      Predict the text the two events print.

      Starter codePop out in the code editor (opens in a new tab)JavaScript
      const target = new EventTarget();
      target.addEventListener("save", () => console.log("saved once"), { once: true });
      target.dispatchEvent(new Event("save"));
      target.dispatchEvent(new Event("save"));

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticePredict a tiny signal update

        Type the number the subscriber prints.

        Starter codePop out in the code editor (opens in a new tab)JavaScript
        let count = 1;
        const subscribers = new Set();
        const read = () => count;
        read.set = (next) => {
          count = next;
          for (const subscriber of [...subscribers]) subscriber();
        };
        subscribers.add(() => console.log(read() * 2));
        read.set(3);

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengeChoose a tool for a value worked out from other values

          A checkout page needs total to update when price or quantity changes. Only the parts of the page that show total should update. Which tool fits best?

          Answer, then press Check. Spacing and letter case don’t matter.

            Check your understanding

            7 QUESTIONS
            Lesson quiz · 7 questionsScore: first tries count
            1. Question 1 of 7Which sentence best describes the observer pattern?

              Choose an answer to see the explanation.

            2. Question 2 of 7What does this loop over a copy print?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              const listeners = [];
              listeners.push(() => console.log("A"));
              let stopB = () => {};
              stopB = () => listeners.splice(1, 1);
              listeners.push(() => { console.log("B"); stopB(); });
              listeners.push(() => console.log("C"));
              for (const listener of [...listeners]) listener();
              console.log(listeners.length);

              Choose an answer to see the explanation.

            3. Question 3 of 7What prints from the pub/sub topic example?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              const topics = new Map();
              function on(topic, handler) {
                topics.set(topic, [...(topics.get(topic) ?? []), handler]);
              }
              function emit(topic, value) {
                for (const handler of topics.get(topic) ?? []) handler(value);
              }
              on("cart:changed", (count) => console.log("header " + count));
              on("analytics:cart", (count) => console.log("analytics " + count));
              emit("cart:changed", 2);

              Choose an answer to see the explanation.

            4. Question 4 of 7What does { once: true } do here?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              const target = new EventTarget();
              target.addEventListener("save", () => console.log("saved once"), { once: true });
              target.dispatchEvent(new Event("save"));
              target.dispatchEvent(new Event("save"));

              Choose an answer to see the explanation.

            5. Question 5 of 7Why is the error event special on Node's EventEmitter?

              Choose an answer to see the explanation.

            6. Question 6 of 7What does this tiny signal code print?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              let count = 1;
              const subscribers = new Set();
              const read = () => count;
              read.set = (next) => {
                count = next;
                for (const subscriber of [...subscribers]) subscriber();
              };
              subscribers.add(() => console.log(read() * 2));
              read.set(3);

              Choose an answer to see the explanation.

            7. Question 7 of 7What is the most useful difference between signals and observables?

              Choose an answer to see the explanation.

            Key takeaways

            • With observer, the subject keeps the list of listeners and calls them.
            • With pub/sub, a bus keeps lists by topic, so the publisher never knows who is listening.
            • Copy the list before calling listeners, give back an unsubscribe function, and catch each listener's errors.
            • In the browser, use EventTarget. In Node, use EventEmitter.
            • A signal is a value you can read now. An observable sends values over time.

            Remember the one-liner.
            Let code react to a change by deciding who keeps the list of listeners, how messages find them, and when they are removed.

            Up next: more design patterns, such as decorator, adapter, and strategy. A later project builds a complete event emitter from scratch.

            CompleteFrontend Clear concepts. Working examples.