Loading indication with a delay and anti-flickering in RxJS

Here's yet another version. This one uses timeout to end the query at 10s. And uses throttleTime to prevent the loader flashing. It also only subscribes to the query once. It produces an observable that will emit the showLoader boolean and eventually the result of the query (or an error).

// returns Observable<{showLoader: boolean, error: Error, result: T}>
function dataWithLoader(query$) {
   const timedQuery$ = query$.pipe(
       // give up on the query with an error after 10s
       timeout(10000),
       // convert results into a successful result
       map(result => ({result, showLoader: false})),
       // convert errors into an error result
       catchError(error => ({error, showLoader: false})
   );

   // return an observable that starts with {showLoader: false}
   // then emits {showLoader: true}
   // followed by {showLoader: false} when the query finishes
   // we use throttleTime() to ensure that is at least a 1s
   // gap between emissions.  So if the query finishes quickly
   // we never see the loader
   // and if the query finishes _right after_ the loader shows
   // we delay its result until the loader has been
   // up for 1 second
   return of({showLoader: false}, {showLoader: true}).pipe(
       // include the query result after the showLoader true line
       concat(timedQuery$),
       // throttle emissions so that we do not get loader appearing
       // if data arrives within 1 second
       throttleTime(1000, asyncScheduler, {leading:true, trailing: true}),
       // this hack keeps loader up at least 1 second if data arrives
       // right after loader goes up
       concatMap(x => x.showLoader ? EMPTY.pipe(delay(1000), startWith(x)) : of(x))
   );
}

First of all, this is a nice question, Lukas!

Foreword: while there are other ways to achieve what you ask, I just wanted to make my answer more like a detailed step-by-step tutorial. Do take a look at Brandon's amazing solution, right below this one.

For convenience, let's imagine that we have a method that does the request and returns us an Observable of string messages:

const makeARequest: () => Observable<{ msg: string }>;

Now we can declare our Observables that will hold the result:

// Our result will be either a string message or an error
const result$: Observable<{ msg: string } | { error: string }>;

and a loading indication:

// This stream will control a loading indicator visibility
// if we get a true on the stream -- we'll show a loading indicator
// on false -- we'll hide it
const loadingIndicator$: Observable<boolean>;

Now, to solve #1

If the data arrives successfully earlier than in 1 second, no indicator should be shown (and data should be rendered normally)

We can set a timer for 1 second and turn that timer event into a true value, meaning that loading indicator is shown. takeUntil will ensure that if a result$ comes before 1 second — we wont show the loading indicator:

const showLoadingIndicator$ = timer(1000).pipe(
  mapTo(true),       // turn the value into `true`, meaning loading is shown
  takeUntil(result$) // emit only if result$ wont emit before 1s
);

#2

If the call fails earlier than in 1 second, no indicator should be shown (and error message should be rendered)

While the first part will be solved by #1, to show an error message we'll need to catch an error from the source stream and turn it into some sort of { error: 'Oops' }. A catchError operator will let us do that:

result$ = makeARequest().pipe(
  catchError(() => {
    return of({ error: 'Oops' });
  })
)

You might've noticed that we're kind of using the result$ in two places. This means that we'll have two subscriptions to the same request Observable, which will make two requests, which is not what we desire. To solve this, we can simply share this observable among subscribers:

result$ = makeARequest().pipe(
  catchError(() => { // an error from the request will be handled here
    return of({ error: 'Oops' });
  }),
  share()
)

#3

If the data arrives later than in 1 second an indicator should be shown for at least 1 second (to prevent flashing spinner, the data should be rendered afterwards)

First, we have a way to turn the loading indicator on, though we currently don't turn it off. Lets use an event on the result$ stream as an notification that we can hide the loading indicator. Once we receive a result — we can hide the indicator:

// this we'll use as an off switch:
result$.pipe( mapTo(false) )

So we can merge the on-off switching:

const showLoadingIndicator$ = merge(
  // ON in 1second
  timer(1000).pipe( mapTo(true), takeUntil(result$) ),

  // OFF once we receive a result
  result$.pipe( mapTo(false) )
)

Now we have loading indicator switching on and off, though we need to get rid of loading indicator being flashy and show it at least for 1 second. I guess, the simplest way would be to combineLatest values of the off switch and a 2 seconds timer:

const showLoadingIndicator$ = merge(
  // ON in 1second
  timer(1000).pipe( mapTo(true), takeUntil(result$) ),

  // OFF once we receive a result, yet at least in 2s
  combineLatest(result$, timer(2000)).pipe( mapTo(false) )
)

NOTE: this approach might give us a redundant off switch at 2s, if the result was received before 2nd second. We'll deal with that later.

#4

If the call fails later than in 1 second an indicator should be shown for at least 1 second

Our solution to #3 already has an anti-flash code and in #2 we've handled the case when stream throws an error, so we're good here.

#5

If the call takes more than 10 seconds the call should be canceled (and error message displayed)

To help us with cancelling long-running requests, we have a timeout operator: it will throw an error if the source observable wont emit a value within given time

result$ = makeARequest().pipe(
  timeout(10000),     // 10 seconds timeout for the result to come
  catchError(() => {  // an error from the request or timeout will be handled here
    return of({ error: 'Oops' });
  }),
  share()
)

We're almost done, just a small improvement left. Lets start our showLoadingIndicator$ stream with a false value, indicating that we're not showing loader at the start. And use a distinctUntilChanged to omit redundant off to off switches that we can get due to our approach in #3.

To sum up everything, heres what we've achieved:

const { fromEvent, timer, combineLatest, merge, throwError, of } = rxjs;
const { timeout, share, catchError, mapTo, takeUntil, startWith, distinctUntilChanged, switchMap } = rxjs.operators;


function startLoading(delayTime, shouldError){
  console.log('====');
  const result$ = makeARequest(delayTime, shouldError).pipe(
    timeout(10000),     // 10 seconds timeout for the result to come
    catchError(() => {  // an error from the request or timeout will be handled here
      return of({ error: 'Oops' });
    }),
    share()
  );
  
  const showLoadingIndicator$ = merge(
    // ON in 1second
    timer(1000).pipe( mapTo(true), takeUntil(result$) ),
  
    // OFF once we receive a result, yet at least in 2s
    combineLatest(result$, timer(2000)).pipe( mapTo(false) )
  )
  .pipe(
    startWith(false),
    distinctUntilChanged()
  );
  
  result$.subscribe((result)=>{
    if (result.error) { console.log('Error: ', result.error); }
    if (result.msg) { console.log('Result: ', result.msg); }
  });

  showLoadingIndicator$.subscribe(isLoading =>{
    console.log(isLoading ? '⏳ loading' : '🙌 free');
  });
}


function makeARequest(delayTime, shouldError){
  return timer(delayTime).pipe(switchMap(()=>{
    return shouldError
      ? throwError('X')
      : of({ msg: 'awesome' });
  }))
}
<b>Fine requests</b>

<button
 onclick="startLoading(500)"
>500ms</button>

<button
 onclick="startLoading(1500)"
>1500ms</button>

<button
 onclick="startLoading(3000)"
>3000ms</button>

<button
 onclick="startLoading(11000)"
>11000ms</button>

<b>Error requests</b>

<button
 onclick="startLoading(500, true)"
>Err 500ms</button>

<button
 onclick="startLoading(1500, true)"
>Err 1500ms</button>

<button
 onclick="startLoading(3000, true)"
>Err 3000ms</button>

<script src="https://unpkg.com/[email protected]/bundles/rxjs.umd.min.js"></script>

Hope this helps

Tags:

Angular

Rxjs