Home > Software engineering >  How do I prevent API calls fired from an Angular observable subscription from overlapping?
How do I prevent API calls fired from an Angular observable subscription from overlapping?

Time:06-04

In my Angular app, I have a ProductPageComponent, which displays products I get from my server.

I also have a FiltersService that stores an observable which changes value when I toggle a filter (eg. only show available products, only show products that are blue...). In my ProductPageComponent, I have subscribed to this observable so that any change in the filters triggers an API call for the updated list of products:

// This is placed inside the component's constructor
this.filtersService.getStaticFiltersObservable()
      .pipe(
        takeUntil(this.subscriptionSubject$),
        distinctUntilChanged((a, b) => JSON.stringify(a) === JSON.stringify(b))
      )
      .subscribe((filters) => {
        productService.getProductsWithFilters(filters)
          .then((products) => {
            this.productList = products;
          });
      });

The problem with this is, if for any reason the filters observable changes value twice very quickly, the API call for the products is done twice; and if by chance the first call finishes after the second one (which has the latest version of the filters, so it's the one I want), the product list obtained from the first call will overwrite the one obtained from the second.

How do I prevent this and make sure the product list I display is always the latest version?

CodePudding user response:

An acceptable approach would be by using a debounceTime operator, if the changes in the filter you're trying to ignore are really fast (like, a matter of milliseconds between them):

this.filtersService.getStaticFiltersObservable()
  .pipe(
    debounceTime(300),
    takeUntil(this.subscriptionSubject$)
  )
  .subscribe((filters) => {
    this.productService.getProductsWithFilters(filters)
      .then((products) => {
        this.productList = products;
      });
    });

As a comment, the takeUntil(...) operator should be the last operator in the pipe in 99% of the cases, otherwise, it won't cancel subscriptions started by other operators down the pipe(...) chain. There are very rare cases where you want to use it in between operators in the pipe(...).

A better approach

Looking at your code it looks like to me you're new to Angular and the whole observables world. A better approach to what you're doing (the "Angular way") would be:

// This goes on the declaration, not in the constructor,
// to keep the constructor code cleaner
productList$ = this.filtersService.getStaticFiltersObservable()
  .pipe(
    debounceTime(300),
    switchMap((filters) => from(this.productService.getProductsWithFilters(filters))
    takeUntil(this.subscriptionSubject$)
  );

...

constructor(
  private productService: ProductService,
  private filterService: FilterService
) {}

And in your template you can use productList$ this way:

<ul>
  <li *ngFor="let p of productsList$ | async">{{p.name}}</li>
</ul>

It would be even better if you had access to the products service and could turn its returning value into an observable instead of a promise. It would save us that from(...) operator we're using above to wrap the calling to the getProductsWithFilters(...) method.

Also, even though switchMap(...) could do the trick alone, I'm keeping the debouncTime(...) for this use case because it seems to me that it optimizes the usage of bandwidth and of the server resources by preventing early requests from starting (that would be ignored later on anyway because switchMap aborts previous ongoing subscriptions when it subscribes to a new observable - yeah, I know this sounds confusing :D).

In Angular 14 (and, likely, in greater versions)

Just for information, Angular 14 release brought a new way of injecting services: the inject(...) function. As of this writing, I'd say you could follow up on the news to be updated on how to use it the best way, but I wouldn't start using it in production code immediately. It's just a new flavor of DI introduced by Angular and it's not intended to deprecate the constructor injections. Some people just think the inject(...) function keeps the code clean and brings in new possibilities.

// Instead of injecting in the constructor, we can
// inject the services by using the inject(...) function
// *only* in the declaration part of a class attribute
productService = inject(ProductService);
filterService =  inject(FilterService);

productList$ = this.filtersService.getStaticFiltersObservable()
  .pipe(
    debounceTime(300),
    switchMap((filters) => from(this.productService.getProductsWithFilters(filters))
    takeUntil(this.subscriptionSubject$)
  );
<ul>
  <li *ngFor="let p of productsList$ | async">{{p.name}}</li>
</ul>

CodePudding user response:

As the comments already suggests you want to work with the switchMap operator to cancel the first request when the second value from the filter comes faster than the first request takes to finish.

It could look like this:

this.filtersService.getStaticFiltersObservable()
      .pipe(
        takeUntil(this.subscriptionSubject$),
        distinctUntilChanged((a, b) => JSON.stringify(a) === JSON.stringify(b)),
        switchMap((filters) => from(productService.getProductsWithFilters(filters)))
      )
      .subscribe((products) => {
           this.productList = products;
      });
  • Related