3

I would like to know what is the best way using the RxJS library to execute 3 http requests that depends from the previous result.

Let's imagine that I've 3 services in my Angular application and each of them have a function get(id: number) use to subscribe an observable of the request entity.

I need to call sequencing the first service to get an entity which contains an identifier required for the next call by using the second service which also contains an identifier required for the next call using the third service.


Method 1: Using three subscriptions and set each result to global variables

const firstEntityId = 1;

this.firstService.get(firstEntityId)
  .subscribe((firstEntity: FirstEntity) => {
    this.firstEntity = firstEntity;

    this.secondService.get(firstEntity.secondEntityId)
      .subscribe((secondEntity: SecondEntity) => {
        this.secondEntity = secondEntity;

        this.thirdService.get(secondEntity.thirdEntityId)
          .subscribe((thirdEntity: ThirdEntity) => {
            this.thirdEntity = thirdEntity;

          });
      });
  });

Method 2: Using function with stream and one subscription to set all global variables

const firstEntityId = 1;

this.getFirstSecondThird(firstEntityId)
  .subscribe(([firstEntity, secondEntity, thirdEntity]: [FirstEntity, SecondEntity, ThirdEntity]) => {
    this.firstEntity = firstEntity;
    this.secondEntity = secondEntity;
    this.thirdEntity = thirdEntity;
  });

getFirstSecondThird(id: number): Observable<[FirstEntity, SecondEntity, ThirdEntity]> {
  return this.firstService.get(id).pipe(
    switchMap((firstEntity: FirstEntity) => forkJoin(
      of(firstEntity),
      this.secondService.get(firstEntity.secondEntityId)
    )),
    switchMap(([firstEntity, secondEntity]: [FirstEntity, SecondEntity]) => forkJoin(
      of(firstEntity),
      of(secondEntity),
      this.thirdService.get(secondEntity.thirdEntityId)
    ))
  );
}

In this case, does the method using stream is the fastest one ?

Is there an other way to write my function getFirstSecondThird instead of using switchMap and forkJoin methods ?

(I've seen combineLatest but I didn't found how to pass a parameter from the previous result)

Paulo Avelar
  • 2,140
  • 1
  • 17
  • 31
VinceCOT
  • 33
  • 4
  • `combineLatest` is more appropriate when the calls are independent, but you need them to occur in sequence. The first method is more readable, what don't you like about it? –  Dec 02 '18 at 21:11
  • Exactly concerning combineLatest I thought that too and about my two approaches I prefer the second one because it's more reusable if I want to move this function directly into a service to be call in different places. I also prefer when I've only one subscription at the end but I'm still a novice using RxJS and I don't really know about performances. – VinceCOT Dec 02 '18 at 22:47

3 Answers3

1

Maybe use map instead subscribe in method 1?

Note, you need to return at all nested levels. In the example I have removed the brackets so the return is implied.

getFirstSecondThird(id: number): Observable<[FirstEntity, SecondEntity, ThirdEntity]> {
  return this.firstService.get(id).pipe(
    mergeMap((first: FirstEntity) => 
      this.secondService.get(first.secondEntityId).pipe(
        mergeMap((second: SecondEntity) => 
          this.thirdService.get(second.thirdEntityId).pipe(
            map((third: ThirdEntity) => [first, second, third])
          )
        )
      )
    )
  )
}

Here is a test snippet,

console.clear()
const { interval, of, fromEvent } = rxjs;
const { expand, take, map, mergeMap, tap, throttleTime } = rxjs.operators;

const firstService = (id) => of(1)
const secondService = (id) => of(2)
const thirdService = (id) => of(3)

const getFirstSecondThird = (id) => {
  return firstService(id).pipe(
    mergeMap(first => 
      secondService(first.secondEntityId).pipe(
        mergeMap(second => 
          thirdService(second.thirdEntityId).pipe(
            map(third => [first, second, third])
          )
        )
      )
    )
  )
}

getFirstSecondThird(0)
  .subscribe(result => console.log('result', result))
<script src="https://cdnjs.cloudflare.com/ajax/libs/rxjs/6.3.3/rxjs.umd.js"></script>

You might use switchMap() instead of mergeMap() if there is the possibility of getFirstSecondThird() being called a second time but before all the fetches of the first call have completed, and you want to discard the first call - for example in an incremental search scenario.

  • Thanks for sharing this I didn't see that approach but it seems more appropriate than using switchMap and forkJoin. – VinceCOT Dec 02 '18 at 22:52
  • You were pretty close on the 2nd method, but can also use some plain JS ideas like closure with RxJs, so `forkjoin()` is not needed since the first result is already in scope. –  Dec 02 '18 at 23:40
  • I've just tried your function and I realize that I don't understand the return of this and how to handle at the end. It seems that the return is typed Observable>>. Is it possible to have only one emission and one Observable containing these 3 entities to be easier to subscribe by getting all at the same time ? – VinceCOT Dec 03 '18 at 09:43
  • My bad, we need `mergeMap()` in the outer levels to flatten the output. –  Dec 03 '18 at 21:33
  • No problem and thank you very much for editing your example it works as expected but I'm still concern about the different return type. By using this way the return type became to much permissive by returning Observable<(FirstEntity | SecondEntity | ThirdEntity)[ ]> instead of Observable<[FirstEntity, SecondEntity, ThirdEntity]>. I don't understand why this is typed like that because the map function at the end should return an observable on this specific array. – VinceCOT Dec 04 '18 at 10:06
  • I've only run this in the snippet without Typescript. I can see what you are saying - the contents of the array could be any type? Perhaps you can enforce via the return types of `this.firstService.get()`, `this.secondService.get()`, `this.thirdService.get()`? –  Dec 04 '18 at 10:34
  • If I replace your map function "map(third => [first, second, third])" by "switchMap(third => forkJoin(of(first), of(second), of(third)))" it returns the right type but I thought that they do the same job so why the return is different ? – VinceCOT Dec 04 '18 at 10:36
  • `map()` differs from the "higher-order" maps (switchMap, mergeMap and concatMap) in that they flatten nested observables. I tried 3 levels of `mergeMap()` and didn't get the desired result, but found `map()` at level 3 to work. Can pick this up again tomorrow. –  Dec 04 '18 at 10:41
  • You can see `mergeMap(first...` has an observable inside it, same with `mergeMap(second...`, but `map(third...` returns an array not an observable of array, so I would expect that pattern to work. Will test further. –  Dec 04 '18 at 10:45
  • I just noticed you replaced `map(third...` with `switchMap(third => forkJoin(...` so that conforms to the principle of flattening a nested observable, but to me it looks like an unnecessary complication (converting values to observables, then using forkJoin to make the array, then flattening back to an array of values). Plus it's then pretty much the same as method 2. –  Dec 04 '18 at 10:52
  • This is exactly the same feeling I have by using unnecessary forkJoin. But it seems it's the only function that can return the right type among these 3 differents way : map((third) => [first, second, third]) // return type Observable<(First | Second | Third)[]> or switchMap((third) => of([first, second, third]) // return type Observable<(First | Second | Third)[]> or switchMap((third) => forkJoin(of(first, of(second), of(third))) // return type Observable<[First, Second, Third]> – VinceCOT Dec 04 '18 at 11:02
  • One more thing to try - I think that Typescript is inferring a looser return type because we did not type the variables `first`, `second`, `third`. In fact, as long as your services return the correct type, there is no way they can be wrong. But try adding types as I have amended in the top sample. –  Dec 04 '18 at 11:02
0

I would make use of the tap operator. It's generally used for debugging purposes, but is great when you need to implement side effects, especially within a chain of observables.

this.firstService.get(firstEntityId).pipe(
  tap((firstEntity: FirstEntity) => this.firstEntity = firstEntity),
  switchMap((firstEntity: FirstEntity) => this.secondService.get(firstEntity.firstEntityId)),
  tap((secondEntity: SecondEntity) => this.secondEntity = secondEntity),
  switchMap((secondEntity: SecondEntity) => this.thirdService.get(secondEntity.secondEntityId))
).subscribe((thirdEntity: ThirdEntity) => {
  this.thirdEntity = thirdEntity;
  // Rest of the code goes here
});

You could even use tap for assigning this.thirdEntity as well, and then use subscribe for subsequent code only.

Jeto
  • 14,596
  • 2
  • 32
  • 46
  • With `tap` and side-effects, you need to watch out when there is more than one subscription. –  Dec 02 '18 at 21:08
  • @eric99 Could you elaborate a bit on that? I'm still not entirely comfortable with RxJS so I would love to hear more about it. I just came across [this post](https://stackoverflow.com/a/49185749/965834) which gives some interesting info as well. – Jeto Dec 02 '18 at 21:12
  • If as @VinceCOT suggests we want to make a function out of the sequence of code, and then happens to subscribe to it in multiple places, the side-effects will run for each subscription, as this is a cold observable and it runs from the top each time it is subscribed to. –  Dec 02 '18 at 21:16
  • @eric99 Since he's using Angular, my guess is he needs to do this within a component where these properties exist. If he wants to use that within a shared place like a service, then he probably should make the observable hot (with something like `shareReplay`, though I'm not an expert on that either). – Jeto Dec 02 '18 at 21:26
  • My feeling is that going more complicated is not the answer. –  Dec 02 '18 at 21:34
  • Sorry, that sounds condescending. Indeed, he may only want the api to be called once. In that scenario an `AsyncSubject` is a good pattern. However, getting away from the original question here. –  Dec 02 '18 at 21:36
  • I might also comment that using `tap` tends to make the code more procedural in nature. In general, Rx works best when it is constructed in a functional way (which means no side-effects). –  Dec 02 '18 at 21:43
  • @eric99 No problem, you seem to have more experience than me on the subject anyway. I feel like this is a clean solution if it works for him, and might be better if he needs to do more stuff in between calls (though in that case just doing it like his very first solution might be more appropriate). Well, he'll have some choices at least :) – Jeto Dec 02 '18 at 21:46
0

You don't need the forkJoin if you use an inner Observable instead:

getFirstSecondThird(id: string): Observable<[FirstEntity, SecondEntity, ThirdEntity]> {
    return this.firstService.get(id).pipe(
        switchMap(first =>
            this.secondService
                .get(first.secondEntityId)
                .pipe(map(second => [first, second]))
        ),
        switchMap(([first, second]: [FirstEntity, SecondEntity]) =>
            this.thirdService
                .get(second.thirdEntityId)
                .pipe(map(third => <[FirstEntity, SecondEntity, ThirdEntity]>[first, second, third]))
        )
    );
}

Here is the whole code in Context with a test:

type FirstEntity = {id: string, secondEntityId: string};
type SecondEntity = {id: string, thirdEntityId: string};
type ThirdEntity = {id: string};

const FIRST_ENTITY: FirstEntity = {id: 'first', secondEntityId: 'second'};
const SECOND_ENTITY: SecondEntity = {id: 'second', thirdEntityId: 'third'};
const THIRD_ENTITY: ThirdEntity = {id: 'third'};

class X {
    firstService = {get: (id) => of(FIRST_ENTITY)};
    secondService = {get: (id) => of(SECOND_ENTITY)};
    thirdService = {get: (id) => of(THIRD_ENTITY)};

    getFirstSecondThird(id: string): Observable<[FirstEntity, SecondEntity, ThirdEntity]> {
        return this.firstService.get(id).pipe(
            switchMap(first =>
                this.secondService
                    .get(first.secondEntityId)
                    .pipe(map(second => [first, second]))
            ),
            switchMap(([first, second]: [FirstEntity, SecondEntity]) =>
                this.thirdService
                    .get(second.thirdEntityId)
                    .pipe(map(third => <[FirstEntity, SecondEntity, ThirdEntity]>[first, second, third]))
            )
        );
    }
}

describe('X', () => {
    it('getFirstSecondThird', async () => {
        // setup
        const x = new X();
        const firstSpy = spyOn(x.firstService, 'get').and.callThrough();
        const secondSpy = spyOn(x.secondService, 'get').and.callThrough();
        const thirdSpy = spyOn(x.thirdService, 'get').and.callThrough();

        // execution
        const result = await x.getFirstSecondThird('first').pipe(toArray()).toPromise();

        // evaluation
        expect(result[0]).toEqual(<any[]>[FIRST_ENTITY, SECOND_ENTITY, THIRD_ENTITY]);
        expect(firstSpy.calls.allArgs()).toEqual([['first']]);
        expect(secondSpy.calls.allArgs()).toEqual([['second']]);
        expect(thirdSpy.calls.allArgs()).toEqual([['third']]);
    });
});
yankee
  • 38,872
  • 15
  • 103
  • 162