angulartypescriptrxjsrxjs-observablesmergemap

Angular rxjs, multiple subscriptions with subject and mergeMap


I have a service that notifies that an array of categories has been filled (via a call to an API on the bootstrap of the application). As soon as these categories are ready I have to start a call to the API for each category contained in the array to find the data details of each category that will then be shown in different sections of the same page.

The notification can occur in two different ways, at the end of the first loading of the app (launched only once) or every time a user browsing the application arrives on the page and the categories array has already been filled.

This is the component code.

export class CategoriesComponent implements OnInit, OnDestroy {
  tagsCategories: TagCategory[]|null = null;
  dateRangeTags: {start: Date, end: Date};
  component_destroy = new Subject<void>();

  constructor(...)
  {
    const d = new Date();
    this.dateRangeTags = {start: this.commonFunc.getOneMonthAgo(d), end: d};
  }

  ngOnInit() {
    console.log('init', this.tagsCategories);
    this.loader.loadingOn();
    if (this.sharedDataService.getTagCategories().length > 0) {
      //categories have already been loaded
      this.sharedData.notifyTagsCategoriesReady();
    }

   
    this.sharedData.categoriesAvailable$
      .pipe(
        tap(() => {
          const categories = this.sharedData.getTagCategories();
          for (const category of categories) {
            category.date_range_request = {...this.dateRangeTags};
          }
          this.tagsCategories = categories;
          console.log('tap side effect', this.tagsCategories);
          this.loader.loadingOff();
        }),
        filter((v,i) => this.tagsCategories !== null && this.tagsCategories.length > 0),
        switchMap(
          () => from(this.tagsCategories!).pipe(
            mergeMap((category: TagCategory) =>
              this.fetchTagsCategories(category.date_range_request!, category)
                .pipe(
                  tap({
                    next: (categories: TagCategory[]) => {
                      if (categories.length > 0) {
                        const cat = categories[0];
                        if (cat.id === category.id) {
                          category.tags_plots = cat.tags_plots;
                        }
                      }
                    },
                    error: (error) => {
                      category.spinner = false;
                    },
                    complete: () => {
                      category.spinner = false;
                    }
                  }),
                  catchError((error) => throwError(() => error))
                )
            )
          )
        )
      )
      .subscribe({
        next: (val) => {
          console.log('final next', val);
        },
        error: (error: string) => {
          this.errorHandler.displayErrorDialog(error);
        },
        complete: () => {}
      })
  }
}

This is the subject in the service and the function that I call to notify the availability of the categories in the two different scenarios.

export class SharedDataService {
  categories: TagCategory[] = [];
  private _categoriesAvailable = new Subject<boolean>();
  categoriesAvailable$ = this._categoriesAvailable.asObservable();
  
  //this is called when the categories are fetched from the server
  setTagCategories(tagCategories: TagCategory[]) {
    this.categories = tagCategories;
    console.log('Set tag categories');
    this._categoriesAvailable.next(true);
  }

  //this is called when the user visit the categories page and the array is already loaded
  notifyTagsCategoriesReady() {
    console.log('notifyTagsCategoriesReady');
    this._categoriesAvailable.next(true);
  }

  getTagCategories() {
    return this.categories;
  }
}

In the component view I inserted a guard to not display the various sections if the array of categories has not yet been initialized or is empty.

<h1 class="nom">{{ 'categories.title' | translate }}</h1>
@if (tagsCategories && tagsCategories.length > 0) {
  @for (tagCategory of tagsCategories; track tagCategory.id) {
<!-- build each section -->
}

When the app is completely reloaded on the category page and then the application is bootstrapped everything works as it should but if you navigate to other pages and then return to that page the various sections are not shown even though the console.log in the tap operator indicates that the array is correctly filled. Furthermore, every time you revisit the page the requests multiply as if the same subscription are added each time, if the array contains three elements every time I revisit the page 3 requests are added. I also tried to insert a takeUntil notifying the destruction of the component but in that case the subscription only works the first time and is never recreated afterwards even though the console.log inside the ngOnInit function are printed.

It's clear that I'm missing a lot of things and maybe there are better implementations to get what I need to do but I would be grateful if someone could tell me why Angular seems to ignore the array state change by not showing the various sections after the first load and above all why the requests multiply every time I visit the page.


Solution

  • Update:

    Make sure you initialize the subscription before you emit the next of the subject.

      ngOnInit() {
        this.sub.add( // <-- first we subscribe them we emit!
          this.sharedData.categoriesAvailable$
            .pipe(
              ...
            )
            .subscribe(...)
        ); 
        ...
        console.log('init', this.tagsCategories);
        this.loader.loadingOn();
        if (this.sharedDataService.getTagCategories().length > 0) {
          //categories have already been loaded
          this.sharedData.notifyTagsCategoriesReady();
        }
      }
    

    The reason for multiplication of calls, is due to the subscription not being unsubscribed. So, we can add the below code:

      private sub = new Subscription();   // <-- changed here! 
      ...
    
      ...
      ngOnInit() {
        ...
        this.sub.add( // <-- changed here!
          this.sharedData.categoriesAvailable$
            .pipe(
              ...
            )
            .subscribe(...)
        ); // <-- changed here!
      }
    
      ...
      ngOnDestroy() {            // <-- changed here!
        this.sub.unsubscribe();  // <-- changed here!
      }                          // <-- changed here!
    }
    

    Then, we should convert the Subject on the service, to a BehaviorSubject because the code inside the component will not trigger, unless the subject has it's next method called, which does not happen during navigation.

    export class SharedDataService {
      categories: TagCategory[] = [];
      private _categoriesAvailable = new BehaviorSubject<boolean>(false); // <-- changed here!
      categoriesAvailable$ = this._categoriesAvailable.asObservable();