I am going to claim something that sounds wrong: every modern frontend framework you have used in the last decade is a re-implementation of the Observer pattern. React, Vue, Svelte, Solid, MobX, Redux, Recoil, signals, RxJS, the DOM event system itself. Different syntax, sometimes very different performance models, but the underlying shape is the one Gamma, Helm, Johnson, and Vlissides wrote down in 1994: a subject holds state, observers register interest, the subject notifies observers when state changes.
My stance for this article: the textbook Observer pattern is so widespread now that the interesting question is not "how do you implement it". The interesting question is which dialect you are working in, what its notification ordering guarantees are, and where it leaks. Most production bugs around reactivity come from confusing the dialects.
The minimal subject-listener loop
If you have not implemented Observer by hand recently, the canonical version in TypeScript looks like this:
Three decisions that snippet makes, all of which are points of variation in real frameworks:
- Notify on subscribe? Some flavors emit the current value the moment a new observer subscribes (RxJS
BehaviorSubjectdoes, Redux'ssubscribedoes not, signals usually do). I made my version emit, so the contract is that subscribers see a value before any external change. - Synchronous or batched? This version notifies synchronously: the moment
setis called, every listener runs, in iteration order, beforesetreturns. React 18 batches state updates within a tick. Vue and MobX schedule a flush after the current synchronous chunk finishes. Each of those choices changes the bug profile of code that reads dependent values immediately after a write. - Iteration order while listeners mutate. I am using a
Set, which iterates insertion order in JS. If a listener unsubscribes a different listener mid-iteration, I am safe withSet.deletebecause the iterator handles it. If a listener subscribes a new listener, it WILL be invoked in the same notify cycle, becauseSetiteration sees the new entry. That is a footgun, and it is worth noticing.
The dialects, in one table
Here is how the major flavors I have shipped against differ. The key columns are the ones that bite at 2 a.m.
A few things worth pointing out about that table.
First, all the "Notify order = insertion order" entries are simple Observer. If you have ten listeners and you call emit, the first one registered runs first. That is the same order you would get from iterating over a Set or a list of callbacks. Predictable, easy to debug, but also fragile: if two unrelated parts of your code happen to subscribe in a particular order, they have an implicit ordering contract.
Second, the DOM is the odd one out. Listeners on a parent and a child element fire in capture-phase order (parent first) and then bubble-phase order (child first), driven by the document tree, not by the registration order. That is why so many tutorials get "event order" wrong; the DOM is not insertion-ordered Observer.
Third, the React/Vue/MobX/Solid family is what I would call dependency-driven Observer. The framework figures out which subscribers depend on which subjects, and only re-runs the affected ones. That is a real change in dialect, and we will get into why next.
Why React rediscovered Observer (in a particular dialect)
The original 2013 React was very explicit about not being Observer. "Just re-render the whole thing" was the pitch. But within two years the community had reinvented Observer on top, in three layers: Redux gave you a subject and listeners, MobX gave you implicit dependency tracking, and eventually React itself shipped Hooks (2018) and signals-style libraries followed.
What made the rediscovery interesting was that React did not adopt the textbook shape. React adopted a dialect with two specific properties:
- Implicit dependency tracking: a component that reads
state.valueautomatically becomes an observer of that field. There is nosubscribecall. That is convenient but also opaque, which is why React DevTools spends so much effort showing you which components re-rendered and why. - Batched, scheduled notification: React 18 will not call you back in the middle of a click handler. It collects the writes, finishes the user-driven code, and schedules a render. That changes the contract. Code like
setX(1); console.log(x)does NOT see the new value ofx, because the notification has not happened yet.
If you came to React from a textbook Observer background, both of those felt like bugs. They are not bugs; they are a different dialect with different invariants. Internalizing that the notification is batched and the dependencies are implicit is most of "learning React".
The even-newer signals-based libraries (Solid, Preact Signals, the upcoming JS proposal) push this further: dependencies are tracked at field granularity, and the re-render cone is much smaller than a React component. But the core shape is still Observer. The signal IS the subject. The component IS the observer. The framework decides who runs after a write.
The reentrancy trap that cost me a Saturday
Here is the bug that cost me a Saturday once, in code I had written. Names changed.
The listener calls recomputeTotals, which calls orders.set, which calls every listener including itself, which calls recomputeTotals, which... you get it. Stack overflow.
The textbook Observer has zero protection against this. RxJS does not protect against it either. React DOES, by tracking re-render depth and bailing on infinite loops. Vue and MobX both have explicit cycle detection during their flush phase. The reason the dependency-driven dialects exist is partly because they catch this kind of thing for you. If your team is implementing Observer by hand, write the cycle guard. I usually use a depth counter and a cap of, say, 32, and throw if it is exceeded; that catches both runaway loops and the more subtle case where two observers ping-pong.
The unsubscribe leak
The second bug I have written more than once: forgetting to call the unsubscribe function.
If you forget the return off, and the component mounts and unmounts in a loop (route changes, list virtualization), the listener set grows without bound. Every old listener still has a closure over a stale setLocalCopy, which still has a closure over the stale React fiber. You get a memory leak and, in development with StrictMode, double-invoked notifications because the same listener was registered twice.
The rule I tell my team: every subscribe must have a matching unsubscribe, and the easiest way to enforce that is to make subscribe return its own teardown function (as in my snippet at the top), so the call site cannot lose the reference. If your Observer API requires the caller to remember the listener identity to pass it back to unsubscribe(listenerFn), you have the Java DOM API, and you will leak.
When I would NOT use Observer
It is not a default. Two cases where I deliberately avoid it:
- Request/response inside one process. If A wants a value from B and the value will be ready in microseconds, just call B. Wrapping it in subscribe-emit-unsubscribe is ceremony for no benefit and forces async semantics on synchronous code.
- One-to-one bookkeeping where the subject's lifetime is shorter than the observer's. I have seen teams introduce Observer for things like "when this form is submitted, run this validator". The subject (the form) outlives the validator, so a callback or a return value is simpler. Observer's payoff is many-to-many or one-to-many over a long-lived subject.
The payoff is real for: anything broadcast (events), anything where the consumer set is open and unknown to the producer (plugins), anything where the producer must not block on consumers (logging, metrics).
The flavor I default to in 2026 codebases
When I introduce a fresh Observer-style abstraction in a new codebase today (one that does not already commit to a framework), I lean on a small primitive that combines the best parts of the dialects above. It looks roughly like this:
Three decisions in there worth defending. The Object.is short-circuit means setting the same value twice does not double-notify, which matters when upstream code is paranoid about "set whenever an event arrives". The snapshot of listeners ([...this.listeners]) means a listener that subscribes a new listener mid-notify does not see itself called in the same cycle, which removes the most surprising form of the iteration footgun I described earlier. The cycle guard catches the runaway-write bug without needing a full transaction system. The whole class fits in thirty lines and I have shipped variants of it in three different production codebases.
One thing I deliberately did not add is an emit-current-value-on-subscribe. I have changed my mind on this twice. In libraries that are essentially state containers (a Redux-style store, a BehaviorSubject), emitting on subscribe is correct. In libraries that are essentially event streams (clicks, websockets, notifications), it is wrong because the "current value" of an event stream is not a meaningful concept. The distinction is whether your subject represents state or events, and the same primitive should not try to do both.
A small checklist before you ship Observer
Before I land an Observer-flavored API, I run through these:
- Does
subscribereturn its own unsubscribe? (Not a separateunsubscribe(listener)method.) - Is the notification order documented? Is the contract "insertion" or "undefined"?
- Is there a cycle guard, or is it documented that listeners must not write back to the same subject?
- Is there a way to detect listener leaks (count, dev-mode warning at N+1 listeners)?
- If multiple subjects can write, is there a flush boundary, or does every write notify immediately?
The last one is the most important and the most often skipped. If your code has more than one Observable and one observer wants a consistent view across them, you need a flush boundary. "Notify-each-subject-immediately" is the easy version that ships, but it leads to observers seeing a partial state and reacting to it. React's batching, MobX's transactions, and Vue's microtask flush all exist to solve exactly this problem.
One sentence of perspective
Observer is so foundational that calling it a "design pattern" undersells it; it is closer to a primitive of how systems of mutable state communicate. The work is not in implementing it. The work is in picking the dialect (synchronous vs batched, insertion-ordered vs dependency-driven) that matches the domain, and then explicitly documenting the order, the cycle behavior, and the unsubscribe contract. Most teams skip the documentation step and pay for it in unreproducible bugs. Do not be that team.
