In my recent article, I highlighted surprising behaviors developers encounter in Angular. It seems fitting to now turn our attention to the quirks present in RxJS—the ever-present companion of Angular.
RxJS has become an indispensable part of any Angular project. We rely on it daily for handling forms, HTTP requests, user events, and a wide array of other tasks. Given its ubiquity, it's tempting to assume we fully grasp its inner workings. Yet, this powerful library often defies our expectations, and it's usually right to do so. Let's explore some of the pitfalls that developers rarely see coming when using RxJS.
The source won't emit with withLatestFrom until its inner Observable has
The problem
This section's title might seem puzzling, but the underlying issue is straightforward. Consider the following scenario:
@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 listen for clicks on the document body and, for each click, we retrieve the latest value from our FormControl and add it to the stream. However, when we execute this code and start clicking, the console remains empty. It's as if the stream is silent. The culprit? The this.control.valueChanges Observable hasn't produced any output yet, leaving it without a "latest" value. Consequently, emissions from our source are dropped until that inner Observable finally has a value to offer.
The reasoning
But why is this the chosen behavior? Surely the RxJS team could have simply passed undefined, right? Not so fast.
- The primary reason is type safety. Without an established latest value, how could RxJS know what default to use? Should it be
null,undefined, or an empty string? "Nullish" or default values can vary drastically between different implementations and algorithms. Thus, it's logical for the library to delegate this choice to us, the developers, ensuring we fully understand the logic we are writing. - Concern for race conditions. Injecting a default value where none naturally exists could led to dangerous race conditions. If one
Observabledepends on another, this dependency should be explicit. Adding a default could trigger unforeseen outcomes that are notoriously difficult to debug.
The solution
The fix is straightforward—if you have a sensible default, just assign it with startWith:
withLatestFrom(this.control.valueChanges.pipe(startWith(null)));
If you're uncertain about a suitable default, you can try to guarantee the inner Observable fires before the source, but only when it makes sense logically. This will strongly depend on the implementation, but the key is to consciously avoid race conditions.
For a deeper dive, check out the reference on withLatestFrom.
toArray waits for the source Observable to complete, not the inner one
The problem
toArray is a useful operator that gathers all emitted values within a source, holding them until the source completes. At that point, it emits a single array containing all values in sequence. A base example:
of(1, 2, 3).pipe(toArray()).subscribe(console.log);
This will simply output the array “[1, 2, 3]”. Straightforward. Now, let’s consider a more realistic case: we have an input for an autocomplete feature. As the user types, we debounce and then make an HTTP request to fetch 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 as expected: after the user pauses typing, we wait 300ms, grab the query, and make the call. The getData function gives us an Observable emitting an array of objects, assuming those objects have firstName and lastName properties. Typing in the input will log that array.
Now, let's say we want to map those objects into a list of full names (firstName + lastName). We need to emit each element, map its properties, and re-aggregate the results into an array using toArray::
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);
After switchMap, we use switchAll to emit individual elements from the incoming array. We then concatenate the names and use toArray to collect the final results. Seem okay? When you open the console and type, nothing comes through. Why? Because toArray doesn't wait for the inner Observable to finish—it waits for the source, meaning the one created by fromEvent. Since that underlying event stream never completes, we end up with no output.
To learn more about switchMap, have a look at this reference.
The reasoning
At first, this behavior might seem like a bug. But if we look closer, it's actually the only logical approach. toArray can't inspect our intentions; there may be one or two observables we want to perform this operation on. If RxJS defaulted to the switched observable, applying toArray to the source would become impossible. That would be overly restrictive. Business logic might demand either behavior, and moving that choice to the developer is the right call.
The solution
There’s a clean way around this. Apply the necessary operations directly to the inner Observable:
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);
In this version, we remove ambiguity. Instead of saying, “switch to an observable and then process its items,” we are saying, “here is an observable; process its values, then make the switch.” Now RxJS is completely clear on what we want to achieve.
take(1) and first() behave differently
The problem
Often, we don’t care about an observable’s full stream; we only want the initial few emissions:
of(1, 2, 3, 4).pipe(take(3)).subscribe(console.log);
Here the source will emit four values, but we'll log just the first three. So far, simple.
Sometimes, we need only the first emission. take(1) handles that. But then why does RxJS also include an operator called first that appears to accomplish the same thing?
A big thank you to Oleksandr Poshtaruk for showing me this difference! Follow him for valuable RxJS content.
The distinction is that first will throw an error when the source Observable finishes without emitting. For example, if you subscribe to a stream in an Angular component, then destroy the component before any value is emitted, first will cause an error. Not knowing this difference can be quite surprising.
The reasoning
To understand why first behaves this way, it is helpful to view it as "I need a single, initial emission from this source, unconditionally". This is a stronger requirement than take(1), which is more like "I'll take the first emission but I'm fine if there isn't one." Business logic may require a value; otherwise, it's a failure worth signaling. Or you might be hunting down tricky race conditions.
The solution
There’s no singular fix here because this isn't a defect—it’s a feature. Understanding the difference is key to picking the right tool for each situation.
We can sometimes skip takeUntil, but there are times we need it even if we think we don't
The problem
We all understand that unsubscribed Observables lead to zombie-subscriptions and memory leaks. Using takeUntil with a Subject emitting on ngOnDestroy is a common and effective precaution. However, there are situations where manual cleanup isn't strictly required:
- HTTP calls finish after the response arrives, so they complete spontaneously. no need to unsubscribe manually.
- Using the
asyncpipe already handles unsubscription automatically. - An
Observablethat inherently completes, e.g., one created from a finite array will emit all its items and then complete on its own.
A tricky situation emerges when we assume an Observable will complete, but in a specific scenario it doesn't or hasn't yet. 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 might think, "We only take the first 10 emissions. After that, the source will complete, so no zombie subscriptions." But consider this: a user visits your component, doesn't trigger any action, and then navigates away. The subscription remains alive because 10 emissions haven't happened yet—and the component is already destroyed. If the user returns, a new subscription is created, leading to duplicate handling. This can easily become a memory leak.
Another example comes up when we assume that an observable we switched to with switchMap will complete, like an HTTP call triggered by FormControl.valueChanges. While the inner completes, switchMap doesn't complete the original source. This often leads to subscription mismanagement.
The reasoning
This often stems from a lack of full awareness rather than a bug. RxJS can't anticipate how we will use it, so we must be explicit and mindful with subscription management.
The solution
The fix lies in deeply understanding how different Observables complete and practicing caution. If you're ever in doubt and not certain what triggers completion, it's wise to default to takeUntil until you're confident.
For more details, refer to this reference covering takeUntil.
Wrap-up
RxJS is a massive and versatile library. We can't possibly anticipate every edge case, but being aware of these common scenarios can help prevent many a headache. Understanding these potential traps in advance empowers us to use RxJS more effectively and write simpler, more robust code.
