RxJS Pitfalls and how to Avoid Them
RxJS is a powerful library to use. With that power, however, comes a lot of complexity. And with that complexity comes a lot of ways to get into trouble.
In this article, I’ll take you through some common pitfalls that you’ll need to look out for when using RxJS, and I’ll be sharing tips on how to avoid them, linking to external resources when I can.
Mishandling subscriptions
Alain Chautard calls this “Subscribing too early” within his RxJS pitfalls article. Essentially, this arises when you’re writing a function whose asynchronous behavior is of interest to a piece of calling code. The problem arises when, within the implementing function, you subscribe and handle side effects within an Observable that would have been of interest to the caller.
In his article, Chautard shows us a LoginService
class that has a login()
method which subscribes to the Observable returned by put()
. It looks something like this:
Notice how any caller using login()
would have no way of knowing when the login was successfully completed. Furthermore, all error handling would have to be handled by the LoginService code, which leads to a bad separation of concerns. This happens because the code subscribes to the observable at the call site, and does not give the client any way of being notified of changes.
One thing we could do to fix this is return the Observable outright, passing all data along to the client.
Notice here that, rather than subscribing to the returned Observable from put()
, we use the map operator to return both the username and the auth token to the caller, rather than having to store anything ourselves.
While this works, it’s often the case that you want the service itself to store the returned data. For example, you may want to access loginService.currentUser
in multiple different components or contexts. Therefore, it makes sense to store that information within the login service itself. In the original article, Chautard uses tap()
to save information about the user. This is definitely a viable strategy, however it has the implication that any component code would have to rely on Angular’s change detection mechanisms to determine any changes. We can get a boost in performance by taking Angular’s change detection out of the equation.
It turns out that we actually can subscribe inside of this method, so long as we allow our client code to be notified of changes in auth state. We can achieve this by leveraging ReplaySubjects to store currentUser
and authToken
in an observable that clients could subscribe to in order to get login info, as well as providing an additional subject for error handling.
This allows our components to use the async$ pipe to subscribe to changes to the logged in user. Components therefore don’t have to rely on Angular’s built-in change detection, and can use push-based change detection for optimal performance.
A few additional things to notice in this example:
- We still return the observable, so that the client has direct access to the information, as well as the ability to handle any errors from the observable should they arise.
- We’ve passed in an argument of
1
to ourReplaySubject
s. This argument is the “buffer” argument, which tells the subject only to retain the latest value it was given. - We’ve kept our
ReplaySubject
s private here, and exposed them as readonly properties usingasObservable()
. We’ll come back to that later in this article.
What to watch out for
Subscribing to an observable whose asynchronous outcome is of interest to calling code, without allowing client code to observe the results of the outcome.
What to do instead
Ensure the observable is returned, or make use of subjects and update their state as part of a subscription to an observable.
Forgetting to unsubscribe
Have you ever seen code like this?
At first glance, this looks innocent enough, but there’s a problem: within ngOnInit()
we subscribe to currentUser$
from our login service, but we never unsubscribe. This is problematic because even when the component is no longer being used, the fact that the LoginService
is a service that exists outside the scope of the component causes the component to be retained in memory! This causes memory leaks and can easily degrade performance if left unchecked. This gets even worse as your component grows in complexity and the number of subscriptions increase.
Instead, you should always be sure to unsubscribe to any subscriptions you manually subscribe to. A good rule of thumb is: subscribe within ngOnInit(), unsubscribe within ngOnDestroy().
Here’s how we could change the above code in order to handle this:
We can also write a unit test to verify that all subscriptions are cleaned up
Note that this is only important to do for Observables that don’t complete, such as store subscriptions, subscriptions from services, etc. For things like HTTP observables, you generally don’t have to worry about unsubscribing from them, since they always complete.
While the above practice works for a single subscription, this will become unwieldy for multiple subscriptions. A clever solution to this is to use a single parent subscription to manage all child subscriptions, and then only call unsubscribe()
on the parent subscriptions. Here’s how we could modify the above code to accomplish it:
Note that the unit tests will remain the same.
Alan Chautard again has an excellent post on this topic from his blog.
If you don’t like the idea of putting subscriptions with .add()
blocks, you could also use takeUntil() in combination with a destroy subject to handle this.
In most cases, you should try to use the aync$ pipe when possible.
What to watch out for
Dangling subscriptions.
What to do instead
Add all subscriptions to a parent subscription, then unsubscribe()
from that parent subscription on destroy.
Not dealing with errors
Error handling in RxJS can be tricky, because there’s not only lots of ways to handle it, but there are many ways it could go wrong. Understanding how to deal with errors effectively is a key part of building robust Observable APIs.
If you haven’t yet, I’d highly recommend reading Angular University’s RxJS error handling guide. It is the most comprehensive guide I have seen on the topic.
What to watch out for
RxJS code that is not at all handling errors.
What to do instead
Ensure that, at least somewhere in your observer chain, you’re accounting for the fact that things could go wrong and you have a strategy for dealing with it when it does.
Reinventing the wheel
While it’s true that you can recreate redux with a single line of RxJS code, it doesn’t necessarily mean you should. When you start to find yourself managing a large amount of stateful data using Observables, you should consider using a pre-built library such as NgRx, NGXS, or Akita. These libraries will not only give you a lot of functionality for free, but they’ll save you from having to maintain your own bespoke solution, and are already used and understood by developers outside of your team, making it easy to onboard new developers.
If you currently have an app that you need to migrate over to one of these state management libraries, I recommend starting off by implementing a view facade to abstract your current logic, and then once you have the facade in place, introduce your state management library of choice. This will ensure your component’s interaction with the underlying state management system won’t change at all, no matter what that solution is, giving you a clean separation of concerns along the way!
What to watch out for
Replicating too much of what other state management libraries out there are doing.
What to do instead
Use an existing state management library. If dealing with existing code that uses bespoke state management, start by refactoring out the view facade and then introduce the state management library behind the facade.
Exposing subjects as part of a read-only API
Many times when writing services, you’ll want to use a subject to be able to write reactive state. However, we have to be careful not to expose the subject itself to the outside world. This would allow any client using the code to update the state of the service, which would break the principle of data encapsulation.
Instead, you should make your subjects private and use asObservable() to expose a readonly version of that subject for use by clients. Let’s take another look at that UserService we used to talk about mishandling subscriptions:
Notice that for all of the subjects we used, we exposed them using asObservable()
. Within our login()
method, users simply call that method and the observables update themselves:
Notice how the observable attributes can only be read from, never written to. This prevents clients from being able to modify the internal state of the service, ensuring clean encapsulation.
What to watch out for
Exposing subjects as part of an API.
What to do instead
Make the subject private and use asObservable() to expose it as a public property.
Nesting subscriptions
This is one that I’ve seen a lot of people do when they’re first starting out with RxJS. It’s very similar to how people nest .then()
calls in Promises when they’re first starting out using them. They’ll have some asynchronous pieces of code that need to be executed in sequence, and they’ll accomplish this by nesting one .subscribe()
inside of another.
For example, imagine that you were building a metrics dashboard application. On load, it fetches a configuration detailing which data it needs to load for a user. It then proceeds to load the data based on the configuration. These are two different asynchronous operations. You want them to be asynchronous so that you can begin to progressively render parts of the page based on the config. Using nested subscribes, that code might look like this:
Notice how, even with two levels of nesting, we are slipping into pyramid of doom territory. What’s worse, we can’t take advantage of our async pipes because we have to store the returned data as properties on the component instance!
Instead, you can use higher-order mapping functions in order to flatten nested Observables into a single Observable. For example, the previous code could be written as
Here, we use the mergeMap operator to take the data returned from the config service, and use it to produce a new observable using the data service. That Observable is then passed down the rest of the operator chain, and allows you to subscribe to it. This is better, but it’s still not quite ideal. We have to subscribe to the data in order to store it on our component, and we also have to use tap()
to add a side-effect of storing the config object before it’s replaced in the operator chain via the mergeMap call.
A better strategy here is to not subscribe at all, and instead store config
and data
as Observables.
Note that we use shareReplay(1)
above to safe-guard against multiple subscriptions firing multiple HTTP requests.
Let’s look at what we’ve accomplished here:
- We’ve completely removed the need for OnInit
- We’ve reduced the amount of code we’ve had to write
- We’ve removed all subscribing and side-effect code completely from the component, making it fully reactive! We could easily add push-based change detection to this component now, and take advantage of the async$ pipe in order to manage Observable subscriptions.
Becoming proficient with higher-order mapping functions, and reactive programming in general, will take some practice, but once you do your code will become a lot cleaner. If you’d like to dive deep into how higher-order mapping functions, and operators in general, work, I wrote an article on that recently :)
What to watch out for
Nested subscriptions.
What to do instead
Use higher-order mapping operators.
Conclusion
If you can eliminate most of these common mistakes from your code, you’ll be well on your way to writing elegant async code with RxJS. What other pitfalls have you run into? Leave it in a response!
Edits
- In the original article, I had originally used an example of
ActivatedRoute
as a subscription in “Forgetting to Unsubscribe”. Rich Ellis pointed out in the comments thatActivatedRoute
unsubscribes automatically, so that example was actually not valid! Big shout out as well to Ward Bell who expanded upon this and provided a lot of context as to howActivatedRoute
deals with component lifecycles.