Richard Barraclough
Richard Barraclough

Reputation: 2994

Using RxJS to remove nested callbacks when things must be done in sequence

I need to do one HTTP request after another, but the second one can't start until after the first one has finished because the second needs as a parameter a value returned from the first.

Here it is with a nested callback which is great because it works and it's fairly clear from reading the code what is happening.

    this.isLoading = true;
    this.firstService.get(this.id)
      .subscribe((response: FirstReturnType) => {
        this.firstThing = response;

        this.secondService.get(this.firstThing.secondId)
          .subscribe(
            (response: SecondReturnType) => {
              this.secondThing = response;
              this.isLoading = false;
            }
      }

The claim I see people making is that nested callbacks are bad and that one should use RxJS to make it better.

However, nobody making these claims has been able to produce a working example. Can you?

Upvotes: 0

Views: 261

Answers (2)

Mrk Sef
Mrk Sef

Reputation: 8062

Your Code Re-written

Here is some code that has a 1-1 correspondence with your code, but it is flattened

this.isLoading = true;
this.firstService.get(this.id).pipe(
  mergeMap((response: FirstReturnType) => {
    this.firstThing = response;
    return this.secondService.get(response.secondId);
  })
).subscribe((response: SecondReturnType) => {
  this.secondThing = response;
  this.isLoading = false;
});

What this gets right: you're using a higher-order observable operator to map a value emitted by one observable into a new observable that you subscribe to. In this case, mergeMap is subscribing for you and getting rid of your nesting.


For Your Consideration

Consider this. The following is about as clean looking at six service calls (each giving some value to the next one) in a row can get if you're not using a higher-order operator:

this.firstService.getThing("First").subscribe(result1 => {
  this.secondService.getThing(result1.value).subscribe(result2 => {
    this.thirdService.getThing(result2.value).subscribe(result3 => {
      this.fourthService.getThing(result3.value).subscribe(result4 => {
        this.fifthService.getThing(result4.value).subscribe(result5 => {
          this.sixthService.getThing(result5.value).subscribe(result6 => {
            console.log("Result Six is: " + result6.value);
          });
        });
      });
    });
  });
});

Here's the exact same thing with mergeMap:

this.firstService.getThing("First").pipe(
  mergeMap(result1 => this.secondService.getThing(result1.value)),
  mergeMap(result2 => this.thirdService.getThing(result2.value)),
  mergeMap(result3 => this.fourthService.getThing(result3.value)),
  mergeMap(result4 => this.fifthService.getThing(result4.value)),
  mergeMap(result5 => this.sixthService.getThing(result5.value)),
).subscribe(result6 => {
  console.log("Result Six is: " + result6.value);
});

If that's not enough to convince you, you can lean a little bit more into some functional programming to make this even cleaner (without repetitively naming each result)

const passValueToService = service => result => service.getThing(result.value);

passValueToService(this.firstService)("First").pipe(
  mergeMap(passValueToService(this.secondService)),
  mergeMap(passValueToService(this.thirdService)),
  mergeMap(passValueToService(this.fourthService)),
  mergeMap(passValueToService(this.fifthService)),
  mergeMap(passValueToService(this.sixthService)),
).subscribe(finalResult => {
  console.log("Result Six is: " + finalResult.value);
});

Or why not lean EVEN harder and keep our list of services in an array?

const [firstS, ...restS] = [this.firstService, this.secondService, this.thirdService, this.fourthService, this.fifthService, this.sixthService];

const passValueToService = service => result => service.getThing(result.value);

passValueToService(firstS)("first").pipe(
  ...restS.map(service => mergeMap(passValueToService(service)))
).subscribe(finalResult => {
  console.log("Result Six is: " + finalResult.value);
});

None of these simplifications are very easily done while nesting subscribe calls. But with the help of some functional currying (and the handy RxJS pipe to compose with), you can begin to see that your options expand dramatically.

Understanding concatMap, mergeMap, & switchMap

The Setup

We'll have 3 helper functions as described here:

/****
 * Operator: intervalArray
 * -----------------------
 * Takes arrays emitted by the source and spaces out their
 * values by the given interval time in milliseconds
 ****/
function intervalArray<T>(intervalTime = 1000): OperatorFunction<T[], T> {
  return s => s.pipe(
    concatMap((v: T[]) => concat(
      ...v.map((value: T) => EMPTY.pipe(
        delay(intervalTime),
        startWith(value)
      ))
    ))
  );
}

/****
 * Emit 1, 2, 3, then complete: each 0.5 seconds apart
 ****/
function n123Stream(): Observable<number> {
  return of([1,2,3]).pipe(
    intervalArray(500)
  );
}

/****
 * maps:
 *   1 => 10, 11, 12, then complete: each 1 second apart 
 *   2 => 20, 21, 22, then complete: each 1 second apart
 *   3 => 30, 31, 32, then complete: each 1 second apart
 ****/
function numberToStream(num): Observable<number>{
  return of([num*10, num*10+1, num*10+2]).pipe(
    intervalArray(1000)
  );
}

The above mapping function (numberToStream), takes care of the map part of concatMap, mergeMap, and switchMap

Subscribing to each operator

The following three snippits of code will all have different outputs:

n123Stream().pipe(
  concatMap(numberToStream)
).subscribe(console.log);
n123Stream().pipe(
  mergeMap(numberToStream)
).subscribe(console.log);
n123Stream().pipe(
  switchMap(numberToStream)
).subscribe(console.log);

If you want to run these back-to-back:

concat(
  ...[concatMap, mergeMap, switchMap].map(
    op => n123Stream().pipe(
      op(numberToStream),
      startWith(`${op.name}: `)
    )
  )
).subscribe(console.log);

concatMap:

concatMap will not subscribe to the second inner observable until the first one is complete. That means that the number 13 will be emitted before the second observable (starting with the number 20) will be subscribed to.

The output:

10 11 12 20 21 22 30 31 32

All the 10s are before the 20s and all the 20s are before the 30s

mergeMap:

mergeMap will subscribe to the second observable the moment the second value arrives and then to the third observable the moment the third value arrives. It doesn't care about the order of output or anything like that.

The output

10 20 11 30 21 12 31 22 32

The 10s are earlier because they started earlier and the 30s are later because they start later, but there's some interleaving in the middle.

switchMap

switchMap will subscribe to the first observable the moment the first value arrives. It will unsubscribe to the first observable and subscribe to the second observable the moment the second value arrives (and so on).

The output

10 20 30 31 32

Only the final observable ran to completion in this case. The first two only had time to emit their first value before being unsubscribed. Just like concatMap, there is no interleaving and only one inner observable is running at a time, but some emissions are effectively dropped.

Upvotes: 2

Reqven
Reqven

Reputation: 1778

You can use switchMap.

this.firstService.get(this.id)
  .pipe(
    tap((response: FirstReturnType) => this.firstThing = response),
    switchMap((response: FirstReturnType) => this.secondService.get(response.secondId)),
    tap((response: SecondReturnType) => {
      this.secondThing = response;
      this.isLoading = false;
    })
  ).subscribe();

Upvotes: 0

Related Questions