Every few months someone declares RxJS dead, and every few months I open a codebase where someone took that literally — and rebuilt debounceTime by hand with an effect, a signal, and a setTimeout. It's twenty lines, it has a race condition, and it makes the case better than I can: signals didn't replace streams. They took over half the job, the half they're better at.
The division of labor
Ask one question about the thing you're modeling: is it a value or an occurrence?
A value has a current state that's always meaningful to read: the logged-in user, the selected tab, the filter text, the cart. That's signal territory — synchronous reads, glitch-free derivation with computed(), and the template just works. An occurrence is a thing that happens: a keystroke, a websocket frame, a retry with backoff, a request that should cancel its predecessor. Asking for its "current value" doesn't even make sense — what's the current value of a click? That's a stream, and operators like debounceTime, switchMap and retry are decades of distilled answers to time problems signals don't address.
Crossing the border, both directions
toSignal() turns a stream into state. Two details matter more than the docs make obvious. It subscribes immediately — at the call site, not on first read — so calling it lives in an injection context and cleanup is handled for you. And the initial value question is forced on you, which is a feature: an observable may not have emitted yet, so you either provide initialValue, accept undefined in the type, or assert requireSync for things like a BehaviorSubject that emit on subscription.
prices = toSignal(this.ws.prices$, { initialValue: [] });
toObservable() goes the other way, and the canonical use is the typeahead — which is also the cleanest demonstration of the whole philosophy:
query = signal('');
results = toSignal(
toObservable(this.query).pipe(
debounceTime(300),
distinctUntilChanged(),
switchMap(q => this.api.search(q)),
),
{ initialValue: [] },
);
State enters as a signal. The time problems — wait for the user to stop typing, drop stale requests — are handled by the two operators that exist precisely for them. The result comes back out as state. Each tool does the part it was built for, and the seam is two function calls.
The smell test
When reviewing, I use a symmetric pair of smells. An effect() that contains setTimeout, manual cancellation flags, or "ignore this run if a newer one started" bookkeeping — that's a stream wearing a signal costume; it wants five lines of RxJS. Conversely, a service juggling a BehaviorSubject, scan, and shareReplay just to expose "the current list with its loading flag" — that's state wearing a stream costume; it wants to be a signal (or, if HTTP is involved, a resource(), which already ate most of my data-fetching streams — I wrote about that separately).
So the honest status of RxJS in my code: smaller, and better. It left the places it was always awkward — component state, template consumption — and kept the places nothing else touches: coordinating things that happen over time. Fewer streams, but every one that remains is doing work only a stream can do. That's not a technology dying; that's a technology finding its actual shape.
Top comments (0)