When your favorite library doesn’t work as you think 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:

@Component({
  selector: 'my-component',
  template: '<div><input [formControl]="control"/></div>',
})
export class MyComponent implements OnInit {
  control = new FormControl('');

  ngOnInit() {
    fromEvent(document.body, 'click').pipe(
      withLatestFrom(this.control.valueChanges),
    ).subscribe(console.log);
  }
}

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.

  1. 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
  2. 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:

withLatestFrom(this.control.valueChanges.pipe(startWith(null)));

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:

of(1, 2, 3).pipe(toArray()).subscribe(console.log);

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:

fromEvent(document.querySelector('input'), 'input').pipe(
  debounceTime(300),
  map(event => (event.target as HTMLInputElement).value),
  switchMap(query => getData(query)),
).subscribe(console.log);

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:

fromEvent(document.querySelector('input'), 'input').pipe(
  debounceTime(300),
  map(event => (event.target as HTMLInputElement).value),
  switchMap(query => getData(query)),
  switchAll(),
  map(({firstName, lastName}) => firstName + ' ' + lastName),
  toArray(),
).subscribe(console.log);

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:

fromEvent(document.querySelector('input'), 'input').pipe(
  debounceTime(300),
  map(event => (event.target as HTMLInputElement).value),
  switchMap(query => getData(query).pipe(
    switchAll(),
    map(({firstName, lastName}) => firstName + ' ' + lastName),
    toArray(),
  )),
).subscribe(console.log);

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:

of(1, 2, 3, 4).pipe(take(3)).subscribe(console.log);

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 the async pipe — it will unsubscribe automatically
  • Observables that naturally complete — for example, an Observable 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:

@Component({
  selector: 'my-component',
  templateUrl: './my.component.html',
})
export class MyComponent implements OnInit {
  ngOnInit() {
    fromEvent(document.body, 'click')
      .pipe(take(10))
      .subscribe(console.log);
  }
}

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.