Why shouldn't RxJS do everything?
RxJS is amazing, but it has limitations. Consider a counter implemented using a simple variation of the "Subject in a Service" approach:
export class CounterService {
count$ = new BehaviorSubject(0);
increment() {
this.count$.next(this.count$.value + 1);
}
}
Now multiple components can share and react to this state by subscribing to count$
:
Now let's add some derived states with the RxJS map
and combineLatest
operators:
count$ = new BehaviorSubject(1000);
double$ = this.count$.pipe(map((count) => count * 2));
triple$ = this.count$.pipe(map((count) => count * 3));
combined$ = combineLatest([this.double$, this.triple$]).pipe(
map(([double, triple]) => double + triple)
);
over9000$ = this.combined$.pipe(map((combined) => combined > 9000));
message$ = this.over9000$.pipe(
map((over9000) => (over9000 ? "It's over 9000!" : "It's under 9000."))
);
Here's a diagram of these reactive relationships:
Here's what that looks like:
Isn't this easy? RxJS just takes care of everything for us. There's probably nothing wrong with this.
Actually there is. Let's put a console log inside the map
for message$
and see what happens when we increment the count once.
message$ = this.over9000$.pipe(
map((over9000) => {
console.log('Calculating message$', over9000);
return over9000 ? "It's over 9000!" : "It's under 9000.";
})
);
Why did it run 4 times? We only incremented the count once. That's not efficient.
Something weird is going on. Let's put console logs inside each observable so we can get a view into everything happening. And think for a minute about what we should expect. We have a single event, and 5 derived states: double$
, triple$
, combined$
, over9000$
, and message$
. Shouldn't we see 5 console logs? Well, here's what we actually get:
It's over 9000!!! We just implemented our feature in the simplest way possible, and this is what RxJS gave us. This is 40 logs, or 8x what it should be.
We need to understand how subscriptions work. We have 2 components subscribing to several of these observables. Here I've added a colored line for each subscription:
Each subscription gets passed all the way up to the top of the chain. If you count the number of blue and green lines next to double$
and triple
, it's 8 each. That's the number of console logs for each of those. combined$
has 12 lines around it (because of the branching), and 12 logs. But message$
has 2 lines and not 2 but 4 console logs, and over9000$
has 4 lines but 8 console logs. That's because each of those lines ends up splitting into 2 lines up at the combineLatest
.
We have to learn more operators to deal with these problems: map
and distinctUntilChanged
(sometimes with a comparator), combineLatest
and debounceTime
, and shareReplay
. Actually, not shareReplay
, more like publishReplay
and refCount
. Or actually, merge
, NEVER
, share
and ReplaySubject
(more on these later). The really crazy thing is that most people aren't even aware of all these issues. It takes some painful experiences to learn that these operators are necessary.
But asking everyone to avoid the numerous RxJS pitfalls, become intimately familiar with how subscriptions work, and learn all these operators, all just for basic derived state, is absurd. And, these operators increase bundle size and do work at runtime. Creating a custom operator doesn't fix that.
So, while RxJS is amazing for managing asynchronous event streams, it is inefficient and difficult to use for synchronizing states.
How about selectors?
Selectors are pretty efficient at computing derived states.
But I never liked their syntax:
createSelector(
selectItems,
selectFilters,
(items, filters) => items.filter(filters),
);
So for StateAdapt I came up with new syntax:
buildAdapter<State>(...)({
filteredItems: s => s.items.filter(s.filters),
})();
But selectors require a state management library with a global state object, which makes them impossible to integrate tightly with framework APIs, such as component inputs.
Signals
Angular needed a reactive primitive of its own, and out of all the options, signals were the best choice for synchronization.
Let's implement our counter with Angular signals:
count = signal(1000);
double = computed(() => this.count() * 2);
triple = computed(() => this.count() * 3);
combined = computed(() => this.double() + this.triple());
over9000 = computed(() => this.combined() > 9000);
message = computed(() =>
this.over9000() ? "It's over 9000!" : "It's under 9000."
);
Now when we click, we get the 5 expected logs:
It's more efficient than even optimized RxJS, and we only needed one "operator": computed
.
The Angular team did an amazing job with the implementation, too. If you want to learn more about how it works, I recommend this interview they did with Ryan Carniato.
Problems with signals
Signals are awesome, but like RxJS, they have limitations:
- Asynchronous Reactivity
- Eager & Stale State
These will be the topics of my next articles.
Top comments (17)
Heyo Mike! Great post. 🙌
In case ya didn't already know, DEV actually allows folks to embed YouTube & Vimeo videos using the following syntax:
{% embed https://... %}
You def don't need to embed the YouTube video at the top if you'd rather not, but I just wanted to let you know how in case you'd like to.
Hope this is helpful and thanks for sharing this awesome post! 🙂
I‘m really looking forward to signals in Angular. TBH, so never liked rxjs because it’s a complete and complex DSL on top of JS/TS. Not only you have to learn what all these operators do, but you also have to know their implementation as you showed because it has a lot of weird side effects. There is A LOT to do wrong. Signals on the other hand are pretty simple to understand as there are just a few building blocks to learn and it works more intuitively.
And as a plus, it will bring a lot of performance when we can get rid of ZoneJS for change detection.
It will not help in all cases and sometimes you need to use rxjs but I guess that’s acceptable, especially because you can transform signals to and from observables.
There there is still a place for RxJS still for sure. My next article is called "RxJS can save your codebase." But I'm excited at how signals will improve most apps by a lot.
I am looking forward to it. RxJS is very powerful for sure but it has a steep learning curve and the benefit / complexity ratio is not very high IMO. The problem is, there no better alternatives in the JS ecosystem AFAIK. So we have to live with it. I would love to see some better asynch primitives in JS. Async/await is nice but it suffers from the necessity to use it up to the top of the call chain.
RXJS (and state management systems like NGRX) will never go away, it's not that Signals can replace them. Think about complex async side effects that Signals cannot handle.
I think best-of-all worlds is to use NgRX and then expose all selectors as Signals, using the method provided by the NGRX team. I just implemented an app using this approach (where my facades expose all the selectors as signals) and it's a game changing developer experience.
It's fine but NgRx blocks the benefits of RxJS I talk about in the 3rd article in this series.
Greetings! Awesome article!
One question: the four logs that happened for message$, were them only caused by the click or did it include the first value emitted by the BehaviorSubject? I'm trying to make sense of all the logs here and this would "kinda" fit with my assumptions (at least up to the combined$...after that I'm not sure hahahah).
Considering each UI usage, as well as the map operator, generates a subscription:
If the logs are showing both the first value emitted by the BehaviorSubject and the one after clicking the button, the amount of logs for these three would lign up (4 for message$, 8 for over9000$ and 12 for combined$).
The problem in my logic was with double$ and triple$. But when we have half of a working logic, sometimes we want the rest to fit hahahah. So I thought that maybe this cascading of subscription doesn't apply for the combineLatest operator, which could mean double$ and triple$ each only have four subscriptions (two direct UI usages and two direct combined$ usages on UI). Considering two values emitted by the Subject, 8 logs.
Sorry for the really long comment, problems like this one drives me crazy until I understand what's going on hahahahah
Thank you! Keep up the great work!
Thanks for the comment :)
These logs only occurred immediately after I clicked the plus button. There were logs before it with the initial value, but I cleared the console and all of these happened after the click. I triple-checked it, because I myself was surprised.
This article has been shared a lot now, so maybe Ben Lesh will drop by and tell me I'm an idiot about something. I'm sure there's something missing in the way I described it. It seems like there's always another level of understanding with RxJS. But as I said in another comment, it can also be helpful to look at the source code of the operators themselves and see what's going on under the hood.
Such a first thought, why do we create so many dependent streams? We get exactly the same result by simply creating one source of truth:
PS. can't wait once the signals go in permanently!
Thanks for the idea. I think I don't do it this way because it couples states that don't need to be related, and memoization/distinctUntilChanged won't be easy to apply to any of them, or the whole thing (new objects created every time). Each derived state is its own concern and should be able to be easily moved somewhere else.
Hey @wojky
thats a really nice way tio rethink about the solution.
I think after 2 years of watching stateAdapte, It's time for me to start my next project with stateAdapte
It should be fun! It is for me anyway.
1.0.1 is going to have a bugfix for the entity adapter for non 'id' ID props, then I believe 1.1.0 is going to include a way to get a signal for each selector without eagerly subscribing to each selector. It's going to be neat I hope.
Great article, thanks!
I don't quite get why the rxjs implementation was producing such amount of events though. Probably i am not aware of some detaisl of the implementation of rxjs streams, can you advice something to read about it, please?
I don't understand it fully, but RxJS operators just pass on their subscribers normally. So it's as if each final subscriber is directly subscribed to the original sources. The source code of
map
is pretty simple as an example to look at. github.com/ReactiveX/rxjs/blob/mas...all the images seems to be missing @mfp22 , can you fix it please
Great