Search code examples
javascriptarraysangularrxjssubscription

How to handle an array of inner subscriptions with rxjs without nested subscriptions


I have prepared this example on Stackblitz for my question. In this Angular app, I have a NestedSolutionComponent with my current working solution and an AppComponent, where I want to achieve the same result by using proper rxjs operations.

For a real world example which is a bit more complex, I am looking for a solution to map the results of my multiple inner subscription to an array of my outer subscription.

A REST call for a user service provides me this array:

  [
    {
      id: 1,
      name: 'User One',
      groupIds: [1, 3]
    },
    {
      id: 2,
      name: 'User Two',
      groupIds: [2, 3, 4]
    },
  ]

And for each group, I would like to call a REST group service that is providing me further infos about the user group. All in all, I call the group service 5 times, each ID in the group array gets called once. When finished, the result should be mapped into the groups array - but instead of solely having the ID, the whole object should be stored into the array.

The solution should look like this:

  [
    {
      id: 1
      name: 'User One'
      groups: [
        { id: 1, name: 'Group One' },
        { id: 3, name: 'Group Three' }
      ] 
    },
    {
      id: 2
      name: 'User Two'
      groups: [
        { id: 2, name: 'Group Two' },
        { id: 3, name: 'Group Three' },
        { id: 4, name: 'Group Four' }
      ] 
    }
  ]

By nesting subscription, the solution is easy - but ugly. I call the user service first, then calling for each user each groups:

    this.appService.getUsers().subscribe((users) => {
      const _usersWithGroupNames = users.map((user) => {
        const userWithGroupNames = {
          id: user.id,
          name: user.name,
          groups: [],
        } as UserWithGroupNames;
        user.groupIds.forEach((groupId) => {
          this.appService.getGroupById(groupId).subscribe((groupWithName) => {
            userWithGroupNames.groups.push({
              id: groupWithName.id,
              name: groupWithName.name,
            });
          });
        });
        return userWithGroupNames;
      });
      this.usersWithGroupNames.next(_usersWithGroupNames); // Subject
    });

I have spent hours and hours but I really do not see any solution with proper rxjs operators. I tried switchMap and mergeMapbut ended in a hell of nested map operations. Also forkJoin seems not to help me out here, since I receive an array and I have to call the inner subscriptions in a certain order. When I call multiple mergeMaps in a pipe, I cannot access the previous values. I would like to have a solution like that

// not real code, just dummy code
userService.pipe(
  xmap(users => generateUsersWithEmptyGroupArray()),
  ymap(users => users.groups.forEach(group => groupService.getGroup(group)),
  zmap((user, groups) => mapUserWithGroups(user, groups)) // get single user with all group information
).subscribe(usersWithGroups => this.subject.next(usersWithGroups))

Anybody here who knows a proper and readable solution for my problem?

Thanks a lot in advance!


Solution

  • First approach : Used switchMap, mergeMap, from, forkJoin

    this.appService
      .getUsers()
      .pipe(
        switchMap((users) =>
            // for each user
          from(users).pipe(
           // merge map to run parallel for each user
            mergeMap(({ groupIds, ...user }) =>
            // wait to retrive all group details of current user at mergeMap
            // after completing use map to map user with retrived group 
              forkJoin(
                groupIds.map((id) => this.appService.getGroupById(id))
              ).pipe(map((groups) => ({ ...user, groups })))
            )
          )
        )
      )
      .subscribe((result) => {
        console.log(result);
      });
    

    Demo

    In the above code, forkJoin will wait to get all groupIds details for a particular user, and also if he has retrieved group id 3 for the first user it will again retrieve groupId 3 details for user 2 and so on. In short duplicate group, details will be retrieved.

    Second approach : Below is the approach we will get all groupsIds out of the user array, make them unique, get all details of them in parallel, and at the end, we will map group details to users by their groupIds, Here we will not wait for each user group id details to retrieve and also duplicate group details will not be retrieved.

    this.appService
    .getUsers()
    .pipe(
        switchMap((users) =>
          // get all unique groupIds of all users
          from(this.extractUniqueGroupIds(users)).pipe(
            // parallell fetch all group details
            mergeMap((groupId) => this.appService.getGroupById(groupId)),
            // wait to to complete all requests and generate array out of it
            reduce((acc, val) => [...acc, val], []),
            // to check retrived group details
            // tap((groups) => console.log('groups retrived: ', groups)),
            // map retrived group details back to users
            map((groups) => this.mapGroupToUsers(users, groups))
          )
        )
    )
    .subscribe((result) => {
        console.log(result);
        // this.usersWithGroupNames.next(result);
    });
    
    private mapGroupToUsers(users: User[], groups: Group[]): UserWithGroup[] {
        return users.map(({ groupIds, ...user }) => ({
            ...user,
            groups: groupIds.map((id) => groups.find((g) => g.id === id)),
        }));
    }
    
    private extractUniqueGroupIds(users: User[]): number[] {
        const set = users.reduce((acc, { groupIds }) => {
            groupIds.forEach((id) => acc.add(id));
                return acc;
        }, new Set<number>());
    
        return [...set];
    }
    
    interface UserWithGroup {
      id: number;
      name: string;
      groups: any[];
    }
    

    Demo