01 / The idea
The cart should know its quantity. Does it need to know your header?
With one view, you can update the quantity and call renderBadge() right afterward.
That is a perfectly useful starting point. You can see exactly what happens when the value changes.
Then a cart panel needs the same update. A second layout uses a different badge. The panel opens and closes independently. If the cart calls every view directly, its update function also becomes a list of everything currently interested in it.
Observer lets interested objects register a reaction with a subject. When that subject changes, it notifies the registered observers. The cart still knows how to call a listener. It no longer needs to know how a header badge or a panel draws itself.
I like looking at the registration first: “when this changes, call me here.” If you have used a subscription or an event listener, that relationship may already feel familiar. The pattern gives us a name for it—and a reason to ask who owns the unsubscribe.
If you write Svelte, every $effect is already one: it subscribes to each piece
of state it reads while it runs. React’s useEffect subscribes to nothing, which is
why it asks you for a dependency array.
02 / Make it visible
A connection is a promise to call you.
Watch the subscriber list alongside the views. First the views register; then a changed quantity travels through their callbacks. When the panel closes, its registration disappears. The next change has only one listener to call.
One change. Whoever is listening.
A cart. Two views.
The cart owns the value; each view renders it.
Reduced motion: choose a scene to see its completed state.
Read this scene
The cart owns the value; each view renders it.
Cart: 0. Header badge: 0. Cart panel: 0.
Header subscription: absent. Panel subscription: absent.
03 / See the shape
Pass a reaction to the thing that changes.
The basic form is a small signal: register, notify, unregister. The useful form gives it a job in the cart. It stores a quantity and decides which writes count as changes. Follow the listener from the call site into the list and back out through notification.
The subject invokes every current listener; it does not choose which concrete view deserves an update. The callback decides how to use the quantity. That boundary stays the same across the comparison.
A subject keeps a list of callbacks. Subscribe returns a registration ID; unsubscribe removes it. notify calls the current listeners in registration order. This small form has no stored quantity and stops at the first ordinary listener failure.
export type Listener = (quantity: number) => void;
export function createSignal() {
let nextId = 1;
const listeners = new Map<number, Listener>();
return {
subscribe(listener: Listener) {
const id = nextId++;
listeners.set(id, listener);
return id;
},
unsubscribe(id: number) {
listeners.delete(id);
},
notify(quantity: number) {
for (const listener of [...listeners.values()]) listener(quantity);
}
};
} type Listener func(int) error
type subscription struct {
id int
listener Listener
}
type Signal struct {
nextID int
listeners []subscription
}
func (s *Signal) Subscribe(listener Listener) int {
s.nextID++
s.listeners = append(s.listeners, subscription{s.nextID, listener})
return s.nextID
}
func (s *Signal) Unsubscribe(id int) {
for i, entry := range s.listeners {
if entry.id == id {
copy(s.listeners[i:], s.listeners[i+1:])
s.listeners[len(s.listeners)-1] = subscription{}
s.listeners = s.listeners[:len(s.listeners)-1]
return
}
}
}
func (s *Signal) Notify(quantity int) error {
for _, entry := range append([]subscription(nil), s.listeners...) {
if err := entry.listener(quantity); err != nil {
return err
}
}
return nil
} The behavior every example promisesState, notification, failure, and cleanup
The cart starts at 0 and accepts integer quantities from 0 through 9. An invalid write changes nothing. Writing the existing value calls nobody. A changed write stores the new value, then attempts listeners synchronously in registration order.
Each subscribe call creates a distinct numeric ID. Subscribing itself does not call the listener; the caller reads the current quantity to initialize a view. Unsubscribe is safe to repeat. A late subscriber receives future changes, with no replay of older changes.
The practical form reports one result per attempted callback and continues after an ordinary listener failure. A failed notification does not roll back the cart or the views that already updated. The report records only success or failure; a real application would add diagnostics.
Calls are serialized. Callbacks must not change subscriptions during notification; the
examples take a membership snapshot but do not detect this. Recursive reentry through the
same cart is rejected: TypeScript and Python report it through exceptions, and Go
returns an error. No version recovers panics or waits for async
work launched by a listener.
Reading the TypeScriptCallbacks, Map, and snapshots
Listener names a function signature. The Map associates a registration
ID with a callback and preserves insertion order. Copying its entries makes the notification
list explicit. The copied entries still hold the same functions.
try / catch turns a synchronous listener exception into a failed delivery. A
Promise rejection is a different case: this loop does not await an async function, so keep
these callbacks synchronous.
Reading the GoFunction values, slices, and returned errors
A Listener is a function value returning error. The slice
keeps registration order; a map iteration would not provide that guarantee. Copying the
slice fixes the callback list for this notification. Unsubscribe clears the vacated tail
slot so it does not retain a removed callback.
The cart has one sequential owner and no mutex, so only one goroutine should use it. A channel-based design can be useful, but would need its own decisions about blocking, buffering, cancellation, and dropped updates.
Reading the PythonCallables, dictionaries, and exceptions
Listener is a callable type. The basic Signal keeps IDs in a dictionary
and copies its items before delivery, so registration order is explicit and the current
membership is fixed for that notification.
Cart composes that signal, raises ValueError for invalid
writes, and catches ordinary listener exceptions into Delivery values. The finally block releases the re-entry guard even when a callback fails.
04 / Follow the values
Close the panel. Change the cart. Who moves?
Try a changed value with both views open. Then close the panel and change it again. Reopen it to see why a new view reads the current value as well as subscribing for later changes.
The failure switch adds one more decision: if the first listener fails, does the second still receive the update? This lab runs the TypeScript implementation shown above.
Who hears the next change?
Both views have read 0 and subscribed. Try setting the quantity to 1.
05 / Try a decision
Disappearing from the screen is not unsubscribing.
The subject holds a callback. Removing a view does not automatically tell a hand-written subject that the callback is no longer useful.
The panel is gone. Its callback still runs.
You close the cart panel, then change the quantity. A log inside its listener still prints. What belongs in the panel’s cleanup?
06 / Give it a real job
Let the view own the connection.
Imagine a cart session shared by a page’s header and a slide-out panel. The session owns the cart state. The header subscribes for its mounted lifetime. Opening the panel creates its subscription; closing it runs cleanup. The quantity-changing code stays the same when a new view arrives.
Cart session
Validates writes, stores the value, and notifies current registrations.
Each view
Turns a quantity into its own visible output. Keeps rendering decisions local.
Mount / cleanup
Keeps the subscription ID and releases that registration when the view leaves.
If the next feature is a mini-cart in another layout, it can register another reaction. If the next feature is “reserve stock, charge, and confirm the order,” I would give that required workflow an explicit coordinator. A list of optional callbacks is a poor place to hide steps that must succeed together.
Keep the cart scoped to the owning session. In server-rendered applications, user-specific state needs the appropriate request or session boundary.
Build UIs?An effect observes what it reads until its first await, React compares a list instead, and a closing panel has to end its own subscription.
Where it already is in your components
Svelte’s $effect needs no dependency list because it is an observer. While
your effect runs, each piece of state or prop it reads registers the effect with that
value, and every run records the list again. The docs say it tracks values “synchronously read inside its function body (including
indirectly, via function calls)”, and then give the rule this pattern explains: “Values
that are read asynchronously — after an await or inside a setTimeout, for example — will not be tracked.” By the time the code after an await runs, the effect’s run is over, so nothing is registering what it reads.
The order list below reads customerId while its effect runs and status after an await. In a Svelte 5.57 mount, picking Shipped
ran the effect zero more times, so the open orders stayed under a select that said
Shipped, and neither the compiler nor the development build printed a warning. Changing
the customer ran it again. Passing status into load, or reading it before the await, made Shipped fetch again.
React’s effects observe nothing. After a render, React compares the array you wrote with
the one from the last render; in the words of the useEffect docs, “React will compare each dependency with its previous value using the Object.is comparison.” A value you use but leave out is never compared. With [customerId], a React 19.1.0 mount of the same list ran its effect once and
not again when Shipped was picked, leaving the open orders on screen; with [customerId, status] it fetched again. The react-hooks/exhaustive-deps lint rule exists for this. On the broken list, eslint-plugin-react-hooks
7.1.1 reports “React Hook useEffect has a missing dependency: 'status'. Either include it or
remove the dependency array”.
When you have to own it
Now the cart panel from this section. It opens and closes on its own while the header badge keeps going. The framework runs your cleanup when a component unmounts, but a panel that closes with CSS never unmounts. Its callback stays in the cart’s list and keeps hearing every change while nobody can see it. That is the exercise from section 05, one layer up. The move is to tie the subscription to open rather than to mount: subscribe when the panel opens, and unsubscribe when it closes.
Each framework has a hook for a source it does not own, and the panels below use it:
React’s useSyncExternalStore and Svelte’s createSubscriber. React’s docs call its hook “a purpose-built Hook for subscribing to an external store that is preferred” over an effect that copies the value into state. You hand each one a subscribe step whose cleanup
ends the registration, and each reads the cart itself when it renders, so a reopened panel shows
the current value even though our cart does not replay. Our cart’s subscribe returns an ID rather than a cleanup function, so each panel wraps it
in a few lines.
Mounted with the lesson’s cart, both panels held one registration while open and none
after closing, kept showing 1 while the cart moved to 2, showed 2 on reopening, and let go
when unmounted while open. Subscribing whether or not the panel was open left the closed
panel registered, and it moved to 2. The frameworks end the old registration in different
ways. React subscribes again when it receives a different function: “If a different subscribe function is passed during a re-render, React will re-subscribe to the store using the newly
passed subscribe function.” So the panel memoizes it on [cart, open], and a new function on
every render would mean a new subscription on every render. Svelte runs the cleanup once
no effect calls subscribe(), and a closed panel stops calling it. One difference shows only
when something else renders a closed panel: React reads the cart again and shows 2, while
Svelte’s markup does not rerun and keeps 1.
Once several components need that wrapper, it belongs in one place. Fitting the cart to React’s contract, or to a Svelte store’s, whose subscribe function must be “immediately and synchronously called with the store's current value” Svelte: store contract ↗, is a job for an adapter.
An order list that fetches for the customer and the selected status. Svelte stops observing at the await, so the status read after it never reruns the effect; React’s array leaves status out. Pick Shipped in either, and the open orders stay.
import { useEffect, useState } from 'react';
import { fetchOrders, getToken, type Order, type Status } from './components/orders';
export function OrderList({ customerId }: { customerId: string }) {
const [status, setStatus] = useState<Status>('open');
const [orders, setOrders] = useState<Order[]>([]);
const [failed, setFailed] = useState(false);
useEffect(() => {
let ignore = false;
async function load() {
const token = await getToken();
return fetchOrders(token, customerId, status);
}
load().then(
(result) => {
if (ignore) return;
setOrders(result);
setFailed(false);
},
() => {
if (!ignore) setFailed(true);
}
);
return () => {
ignore = true;
};
// Deliberately incomplete. The effect subscribes to nothing: after each render
// React compares this array with the last one, and status is missing, so pick
// Shipped and the effect does not run, and the list keeps the open orders.
// react-hooks/exhaustive-deps flags it; the fix is [customerId, status].
}, [customerId]);
return (
<>
<select value={status} onChange={(event) => setStatus(event.target.value as Status)}>
<option value="open">Open</option>
<option value="shipped">Shipped</option>
</select>
{failed && <p role="alert">Could not load orders.</p>}
<ul>
{orders.map((order) => (
<li key={order.id}>{order.number}</li>
))}
</ul>
</>
);
}
07 / Recognize the relationship
You may already depend on this shape.
These APIs have their own contracts. The useful comparison is the registration and cleanup relationship, then the extra guarantees each API adds.
Node’s EventEmitter
Node documents synchronous listener invocation in registration order. Registering
reactions and later removing them is a familiar expression of this relationship.
EventEmitter adds named events and special handling for 'error'; the delivery
report is the cart’s own choice.
08 / The parts to watch
The pattern leaves some decisions to you.
The name “Observer” does not decide notification timing, equality, error isolation, replay, or ownership. Those choices belong in the contract. They matter even with two listeners.
| Question | This example | A reason to choose differently |
|---|---|---|
| Push or pull? | Push the new scalar quantity. | A large state object may be better read through a snapshot API after an invalidation notice. |
| When are listeners called? | Inside setQuantity, in registration order. | Expensive reactions may need batching or scheduling. Specify how that changes ordering and freshness. |
| What if a listener fails? | Continue and report its ID as failed. | A required operation may need an explicit error-returning workflow. |
| What if subscriptions change during delivery? | The shared examples restrict lifecycle changes to between notifications. | A richer API must define whether removal affects the current delivery, the next one, or both. |
| Who keeps whom alive? | The cart holds callbacks; the owner unsubscribes. | A weak-reference design changes retention and liveness. Garbage collection alone does not release a callback still reachable from a live subject. |
A slow synchronous listener delays later listeners and the setter’s return. Starting async work in a listener adds a different lifetime: unsubscribe can prevent future calls, but it does not cancel a request already started. Cleanup and cancellation may both be necessary.
A listener that writes back to the same subject can create a loop or make later listeners see confusing order. This lesson rejects that recursive write. More involved reactive systems need an explicit policy for scheduling and cycles.
09 / Make the call
Use it when reactions have independent lives.
Observer earns its place when several consumers care about a subject, and those consumers can attach or leave independently. Local view subscriptions, editor selections, and status indicators often have that shape.
Keep a direct call when there is one known dependent and a clear sequence. Use a return value when a caller asked for a result. Consider a derived value when the work is just a calculation over existing state. Subscription machinery adds indirection; the benefit should be visible in ownership or lifetime.
Observer and publish / subscribeLook at who holds the relationship
Our cart holds direct registrations for its own changes. In the publish / subscribe lesson, an intermediary routes topic messages to subscriber inboxes. That introduces routing and delivery policies beyond this subject’s callback list.
The boundary is not “Observer is synchronous, pub/sub is asynchronous.” Either design can make different timing choices. Ask who owns the subscriber list, whether there is a routing intermediary, and what delivery actually promises.
10 / Take the idea with you
Explain the connection without using the name.
“The cart keeps a list of reactions. Each view adds its reaction while it needs updates and removes it when it leaves. A changed quantity goes to the current listeners.” If you can point to who owns each sentence in the code, you have the useful part.
Try transferring it to upload progress. Who owns the progress value? Which views care? What ends each subscription? Then add a slow listener and decide which timing guarantee you actually want.
Connections to follow nextRelated lessons
- Publish / subscribe adds an intermediary for routing messages.
- Mediator gives one object the rule for how several participants respond together; our cart holds direct registrations and applies no such rule.
- Strategy supplies a policy the caller chooses; Observer registers reactions a subject later invokes.
- Composition over inheritance helps explain why a callback can supply the observer role.
- Race conditions in UI becomes relevant when a reaction starts work that finishes later.