RxJS: The Unexpected
When your favorite library doesn’t work as you thought it does
In my recent article I talked about things that are unexpected for developers in Angular; so it is only logical to now focus on some pitfalls that are present in RxJS — the faithful companion of Angular.
RxJS has truly been a given for any Angular application — we use it on a daily basis to work with forms, HTTP calls, events, and so much more. Of course, it is tempting to think that we understand how it works at least with adequate precision. But sometimes this wonderful library challenges what we perceive it to be — and is almost always right to work in such a way. So let’s take a look at different pitfalls developers don’t usually anticipate when working with RxJS.
withLatestFrom allows source to emit only after its inner Observable
The problem
The title of this section may sound confusing, but it’s really simple; just take a look at the following example:
Here we just listen to click on the document’s body, and with each one check the latest emitted value from our FormControl
and add it to the stream. Fair enough? But when we run this example and start clicking on the page, nothing will be logged to the console. As if there are no emissions at all. So what’s the catch? Thing is, the this.control.valueChanges
Observable
hasn’t emitted yet; thus, it does not have a latest value. Subsequently, the emissions from our source are ignored until there is a latest value.
The reasoning
But why exactly does it work this way? Was it hard for the RxJS team to just pass undefined
? Of course not.
- The first and most important reason is type safety. If there is no latest value, how would RxJS know to what should it default? Should it be
null
,undefined
, or maybe an empty string? “Nullish” or default values may differ from one implementation to another even in the simplest algorithms, so it makes sense for the library to place the burden of disambiguation on us, the developers. Thus we an truly understand what we do with our code - Being wary of race conditions. Providing a default value where there is no apparent one may result in dangerous race conditions — if one
Observable
depends on another, we should see it explicitly. Putting a default value may result in unexpected outcomes and uncatchable bugs.
The solution
This one is very easy — if we know a default value, just place it using startWith
:
If we are not sure about the default value, than we can try making sure the inner Observable
fires before the source (but only if it makes sense). This will depend entirely on the implementation, but the main catch is to avoid race conditions.
toArray looks for the source Observable to complete, rather than the one you switched to
The problem
toArray
is a great operator that accumulates all the emitted values without emitting them, and then emits them as a single array in the same order after the source completes. Here is a basic example:
Here this code will just emit the following array: “[1, 2, 3]”. Pretty simple. Now imagine the following scenario: We have an input, that is going to serve as autocomplete; whenever user types something, we make an HTTP call using the query they typed, and get an array of results:
This works simple: whenever the user finishes typing, wait for 300ms, take the query they typed, make the call. The getData
function will return an Observable
of an array of objects. In our case, we can assume it returns an array of objects that contain a firstName
and lastName
. If we open the console and then type something, we will see exactly that.
But now imagine a bit more complex scenario: after we receive the array, we want to have just an array of fullname
-s, (firstName
+ lastName
), so we want to emit all the elements, map them, then recollect them in an array using toArray
. Here is how we do it:
So after we switchMap
to this new Observable
we switchAll
to emit all the elements in that emitted array one by one, add up the firstName
and lastName
and recollect them using toArray
. Looks fine? Okay, but when we open the console and type something inside the input nothing will be emitted. Why is that so? This is because toArray
waits for the source Observable
(the one created by fromEvent
) to complete, rather than the one that we switched to. As that Observable
does not complete, nothing is emitted.
The reasoning
While at first something like this may seem outrageous, or even a mistake on the part of the RxJS team, if we give it an in depth look, we will see this is the only logical thing: toArray
has no way of knowing how you intend to use it, and there are potentially two Observables
that you can want to perform toArray
on: the source Observable
and the one that we switched to. If by default RxJS choosed the switched one, we would have no way of using it on the source Observable
. This is wrong: depending on the implementation or the business logic, we may want different behavior, so depriving us of the ability to use the toArray
operator in the source when switchMap
is used will just create unsolvable problems.
The solution
There is a pretty easy way to handle this — do our operations on the switched Observable
directly. Here is how:
Now in this example the ambiguity is removed; rather than “switch to an Observable
and then work with elements of that said Observable
” we “take an Observable
, work on it, map it’s values, and only then switch to it”. This way RxJS knows exactly what and how we want done and will work as expected.
take(1) and first() are not the same!
The problem
Sometimes we are not interested in all the emissions an Observable
has to offer, and rather need the first several ones:
In this example, the source Observable
will emit 4 values, but we will only log the first 3. Simple enough.
Sometimes the business logic is even simpler, and we are only interested in the very first emission. Of course, we can do this just using take(1)
. But then why does RxJS also offer an operator called first
that seemingly does the same thing?
Special thanks to Oleksandr Poshtaruk for teaching me this! Give him a follow for great info on RxJS
The difference between them is that first
will throw an error if the source Observable
never emits a value. For example, if we subscribe to a stream in our Angular component, then destroy the component without the source emitting, we will get an error when using first
. This can be a source of confusion if we are not aware of the difference.
The reasoning
To understand better why first
behaves this way, it is useful to think of it as “I want to have one and only, the very first, one emission from this source” rather than “I don’t care about emissions other than the first”, which is the take(1)
approach. Sometimes business logic dictates that we need to have at least one value emitted. Or maybe we try to figure out where some race conditions come from.
The solution
There is no exact solution, as this is not a problem itself. Just knowing the difference will give us an edge on deciding which one to use.
We are not always required to use takeUntil, but sometimes we might need it without realising
The problem
We all at this point probably know that unterminated Observables
may cause zombie-subscriptions, which turn result in memory leaks. And we know that using takeUntil
with a Subject
that emits on ngOnDestroy
just always prevents them. Although, there are cases we we do not explicitly need that:
- HTTP calls always complete after the result is available — no need to unsubscribe manually
- If an
Observable
is only used with theasync
pipe — it will unsubscribe automatically Observables
that naturally complete — for example, anObservable
created from an Array will complete on itself as the number of items in an Array is finite
But sometimes we may feel that an Observable
will complete, when in fact there is a scenario where it can live on after the component has been destroyed. Take a look at this example:
We may think that “well, we take only the first 10 emissions, so after that the source will complete, and we won’t have a zombie subscription”. But here is a scenario where something bad can happen: imagine a user loads this page, does not click anywhere and navigates elsewhere. The subscription continues to be active, as 10 emissions have not yet occurred; but our component is already destroy. Than the user may come back to this component and register yet another subscription. It’s easy to see how this, under the worst circumstances, can lead to a memory leak.
In another instance, sometimes we think that if the Observable
to which which switched using switchMap
completes (HTTP call performed on FormControl.valueChanges
, for example), than we don’t have to worry about the source; that could not be farther from the truth: switchMap
does nothing to complete the source Observable
and we should be careful.
The reasoning
The cause of problems like this is our own poor judgement. RxJS cannot know each and every instance of how we may use it, so we should be as explicit as possible.
The solution
The solution to this problem is better understanding the mechanics of how different Observables
complete, and being generally careful. If you are not sure whether an Observable
completes on itself or not, maybe use takeUntil
until you figure it out.
Conclusion
RxJS is a very large multipurpose library. Obviously, we cannot know every possible scenario of how things may go wrong — but this article sheds some light on most popular cases. And knowing of possible problems in advance will always help write better and simpler code.