Guichi
Guichi

Reputation: 2343

rxjs switchMap need to return subscribed observable

Here here the requirement:

When click start button, emit event x times every 100ms, each emit correspond an UI update. When x times emit complete, it will trigger a final UI update, look simple right?

Here is my code:

const start$ = fromEvent(document.getElementById('start'), 'click')
const intervel$ = interval(100)
    .pipe(
        take(x),
        share()
    )
var startLight$ = start$
    .pipe(
        switchMap(() => {
            intervel$
                .pipe(last())
                .subscribe(() => {
                    // Update UI
                })
            return intervel$
        }),
        share()
    )
startLight$
    .subscribe(function (e) {
        //Update UI
    })

Obviously, subscribe inside switchMap is anti-pattern, so I tried to refactor my code:

const startInterval$ = start$
    .pipe(
        switchMapTo(intervel$),
    )
startInterval$.pipe(last())
    .subscribe(() => {
        //NEVER Receive value
    })

const startLight$ = startInterval$.pipe(share()) 

The problem is that intervel$ stream is generated inside switchMap and can not be accessed outside, you can only access the stream who generate interval$, i.e. start$ which never complete!

Is there is smarter way to handle such kind of problem or it was an inherent limitation of rxjs?

Upvotes: 1

Views: 2712

Answers (2)

dmcgrandle
dmcgrandle

Reputation: 6060

You were very close. Use last() inside intervel$ to only emit the final one to the subscribe below. Working StackBlitz. Here are details from the StackBlitz:

const start$ = fromEvent(document.getElementById('start'), 'click');
const intervel$ = interval(100)
    .pipe(
        tap(() => console.log('update UI')), // Update UI here
        take(x),
        last()
    );

const startInterval$ = start$
    .pipe( switchMapTo(intervel$));

startInterval$
    .subscribe(() => {
        console.log('will run once');
    });

Update

If you do not wish to use tap(), then you can simply cause start$ to finish by taking only the first emission and then completing with either take(1) or first(). Here is a new StackBlitz showing this.

const start$ = fromEvent(document.getElementById('start'), 'click')
    .pipe(
      first()
    );
const intervel$ = interval(100)
    .pipe( 
      take(x) 
    );

const startInterval$ = start$
    .pipe( 
      switchMapTo(intervel$)
    );

startInterval$
    .subscribe(
      () => console.log('Update UI'),
      err => console.log('Error ', err),
      () => console.log('Run once at the end')
    );

The downside to this approach (or any approach that completes the Observable) is that once completed it won't be reused. So for example, clicking multiple times on the button in the new StackBlitz won't work. Which approach to use (the first one that can be clicked over and over or the one that completes) depends on the results you need.

Yet Another Option

Create two intervel$ observables, one for the intermediate UI updates and one for the final one. Merge them together and only do the UI updating in the subscribe. StackBlitz for this option

code:

const start$ = fromEvent(document.getElementById('start'), 'click')
const intervel1$ = interval(100)
    .pipe( 
      take(x)
    );
const intervel2$ = interval(100)
    .pipe(
      take(x+1),
      last(),
      mapTo('Final')
    );

const startInterval$ = start$
    .pipe( 
      switchMapTo(merge(intervel1$, intervel2$))
    );

startInterval$
    .subscribe(
        val => console.log('Update UI: ', val)
    );

A more idiomatic way, same logic as previous one (By Guichi)

import { switchMapTo, tap, take, last, share, mapTo } from 'rxjs/operators';
import { fromEvent, interval, merge } from 'rxjs';

const x = 5;

const start$ = fromEvent(document.getElementById('start'), 'click');


const intervel$ = interval(100);

const intervel1$ = intervel$
  .pipe(
    take(x)
  );
const intervel2$ = intervel1$
  .pipe(
    last(),
    mapTo('Final')
  );

const startInterval$ = start$
  .pipe(
    switchMapTo(merge(intervel1$, intervel2$))
  );

startInterval$
  .subscribe(
    val => console.log('Update UI: ', val)
  );

Reflection

The key problem of the original question is to 'use the same observable in different ways', i.e. during the progress and the final. So merge is an pretty decent logic pattern to target this kind of problem

Upvotes: 1

Fan Cheung
Fan Cheung

Reputation: 11345

Put your update logic inside the switchMap and tap() , tap will run multiple time and only last emission will be taken by subscribe()

const startInterval$ = start$
    .pipe(
        switchMap(()=>intervel$.pipe(tap(()=>//update UI),last()),
    )
startInterval$
    .subscribe(() => {
//    will run one time
    })

Upvotes: 1

Related Questions