In diesem Artikel

Working on numerous Angular projects over the past couple of years, it has become evident, that a lot of developers still have a hard time working with RxJS which finds its use in Angular. While RxJS is a very powerful tool, it comes with a lot of operators and a whole paradigm shift. Even experienced developers can be hung up on its intricacies and more than 100 operators.

In this first part of a small series, I want to share some pitfalls we discovered multiple times in code reviews over the years, as well a few patterns we identified as helpful.

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 subscribes 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:

  1. our user navigates to the detail page of Customer1
  2. request Customer1 data object
  3. retrieve Customer1 and update view
  4. request address data for Customer1 ... taking a while due to the API choking
  5. the impatient user of our application navigates to detail page of Customer2
  6. request Customer2 data object
  7. retrieve Customer2 and update view
  8. request address data for Customer2
  9. retrieve address data for Customer2 and update view
  10. 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<number>([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:

You will find that the view is updated twice, as the first request for an address is delayed past the retrieval of the second customers's information, thus resulting in the mismatch of information.

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<number>([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.

Stay tuned!

Related Articles