Nested subscriptions in use
A very common use case is to request a data object from our API to then use some of its properties to request a few more related entities. Finally, we combine the received objects to fill our view.
In Angular we could implement it like this:
this.route.params.pipe(
switchMap(({ customerId }) => customerService.getCustomer(customerId)),
).subscribe(customer => {
this.customer = customer;
addressService.getAddress(customer.addressId)
.subscribe(address => this.address = address);
ordersService.getOrders(customer.orderIds)
.subscribe(orders => this.orders = orders);
});
As implemented above, when the customerId
in our route changes, we request a customer data object from the API. Once received, we use information on the object to request the related address data object and a couple of order records. All our data objects are assigned to a property on our component becoming part of the view.
We have encountered similar implementations on multiple occasions in code reviews and in the following I want to discuss downsides of this implementation as well as suggest a possible fix.
The Bad
Please notice how there are two subscribe
s nested in the outer subscribe
on the getCustomer
request.
In this case we have to handle and clean up three subscriptions (which we omitted in the above example for brevity). If we do not do this properly we can run into various problems, so please clean up after yourself!
With the code above we open up the possibility of a timing issue. Please assume the following sequence of events:
- our user navigates to the detail page of Customer1
- request Customer1 data object
- retrieve Customer1 and update view
- request address data for Customer1 … taking a while due to the API choking
- the impatient user of our application navigates to detail page of Customer2
- request Customer2 data object
- retrieve Customer2 and update view
- request address data for Customer2
- retrieve address data for Customer2 and update view
- retrieve address data for Customer1 and update view … finally resolved!
Due to the delay in the response of the first address, the view is updated with the first address after the second address has been loaded. This results in us displaying the second customer with the first customers’s address!
In the following, I tried to illustrate the above behavior in a more simplified fashion:
// Emitting customerIds 1 and 2 with a 25ms delay
createStream([1, 2], 25)
.subscribe(id => {
// Update the view with the received customer object
updateView('customer', `Customer #${id}`)
// Request address for customer but delay address for Customer #1 by 1000ms
requestAddress(id)
.subscribe(address => updateView('address', address));
});
And you can run this implementation here:
The Fix
In order to fix this behavior, we make use of the switchMap
operator. After receiving the customer id (or the customer) we switch to the stream requesting the address. Furthermore, we use map
to combine the customer and the retrieved address into a single object containing both. Therefore, our stream will carry this bundle of all the information we need to update our view in a single subscription, thus preventing a mismatch.
As an added bonus, switchMap
unsubscribes from the inner stream for us, therefore, the request for the first address will be canceled, as soon as the second customer is emitted.
// Emitting customerIds 1 and 2 with a 25ms delay
createStream([1, 2], 25)
// Switch stream to the address request
.pipe(switchMap(id => {
// Received customer object
const customer = `Customer #${id}`;
return requestAddress(id)
// Combine the customer object and the resolved address to a single object
.pipe(map(address => ({ customer, address })));
}))
.subscribe(({ customer, address }) => {
// Update the view with the matching information
updateView('customer', customer);
updateView('address', address);
});
Please see this fix for yourself:
Bonus
As a little bonus I recreated the initial problem statement more faithfully. In this demo I am simulating a request for a customer object, and requesting multiple related entities, making use of zip
:
Conclusion
We have illustrated how nesting subscriptions can result in timing issues causing unforeseen side-effects. Furthermore, the more subscriptions you have, the more you have to manage unsubscribing, cleanup actions and handling errors. Most of the time the goal should be to use the operators RxJS provides us with to combine our streams to yield a single value. This makes it easier to argue about how our data flows through the stream with no added side-effects.
In the next part of this series we want to discuss another rather common pitfall we have encountered in various reviews and how employing above techniques will help us a different form of potential side-effects.