'Loading indication with a delay and anti-flickering in RxJS

I want to implement loading indication using RxJS (version 6). A loading indicator (a spinner) would be shown in a component before asynchronous data call finishes. I have some rules to implement (whether these rules are correct might be another question, maybe leave a comment):

  • If the data arrives successfully earlier than in 1 second, no indicator should be shown (and data should be rendered normally)
  • If the call fails earlier than in 1 second, no indicator should be shown (and error message should be rendered)
  • 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)
  • If the call fails later than in 1 second an indicator should be shown for at least 1 second
  • If the call takes more than 10 seconds the call should be canceled (and error message displayed)

I am implementing this in an Angular project, but I believe, that this is not Angular specific.

I have found some pieces of this puzzle, but I need help to assemble them together.

In this SO answer there is an implementation of an operator that delays the showing of a loading indicator.

A nice but incomplete implementation for Angular is described in this article.

Showing loading indicator for a minimum amount of time is described in this Medium article.



Solution 1:[1]

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

Solution 2:[2]

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))
   );
}

Solution 3:[3]

You can try to construct a steam in a following fashion.

(Assuming data$ is your data observable that emits when data comes and errors, when it fails)

import { timer, merge, of } from 'rxjs';
import { mapTo, map, catchError, takeUntil, delay, switchMap } from 'rxjs/operators'


const startTime = new Date();
merge(
  data$.pipe(
    takeUntil(timer(10000)),
    map((data) => ({ data, showSpinner: false, showError: false })),
    catchError(() => of({ data: null, showSpinner: false, showError: true })),
    switchMap((result) => {
      const timeSinceStart = (new Date).getTime() - startTime.getTime();
      return timeSinceStart > 1000 && timeSinceStart < 2000 ? of(result).pipe(delay(2000 - timeSinceStart)) : of(result)
    }),
  )
  timer(1000).pipe(
    mapTo({ data: null, showSpinner: true, showError: false }),
    takeUntil(data$)
  ),
  timer(10000).pipe(
    mapTo({ data: null, showSpinner: false, showError: true }),
    takeUntil(data$)
  )
).subscribe(({ data, showSpinner, showError }) => {
   // assign the values to relevant properties so the template can
   // show either data, spinner, or error

});



Solution 4:[4]

EDIT: My old answer had bugs...

I now built a pipeable operator which works, but it's huge. Maybe someone can provide some improvements :)

preDelay is the amount of milliseconds until the loading indicator shows. postDelay is the amount of milliseconds that the loading indicator will at least be visible.

const prePostDelay = (preDelay: number, postDelay: number) => (source: Observable<boolean>) => {
  let isLoading = false; // is some loading in progress?
  let showingSince = 0; // when did the loading start?

  return source.pipe(
    flatMap(loading => {

      if (loading) { // if we receive loading = true
        if (!isLoading) { // and loading isn't already running
          isLoading = true; // then set isLoading = true

          return timer(preDelay).pipe( // and delay the response
            flatMap(_ => {
              if (isLoading) { // when delay is over, check if we're still loading
                if (showingSince === 0) { // and the loading indicator isn't visible yet
                  showingSince = Date.now(); // then set showingSince
                  return of(true); // and return true
                }
              }

              return EMPTY; // otherwise do nothing
            })
          );
        }
      } else { // if we receive loading = false
        if (isLoading) {
          isLoading = false;

          // calculate remaining time for postDelay
          const left = postDelay - Date.now() + showingSince;
          if (left > 0) { // if we need to run postDelay
            return timer(left).pipe( // then delay the repsonse
              flatMap(_ => {
                if (!isLoading) { // when delay is over, check if no other loading progress started in the meantime
                  showingSince = 0;
                  return of(false);
                }

                return EMPTY;
              })
            );
          } else { // if there's no postDelay needed
            showingSince = 0;
            return of(false);
          }
        }
      }

      return EMPTY; // else do nothing
    })
  );
}

Usage:

loadingAction1 = timer(1000, 2000).pipe(
  take(2),
  map(val => val % 2 === 0)
);

loadingAction2 = timer(2000, 2000).pipe(
  take(2),
  map(val => val % 2 === 0)
);

loadingCount = merge([loadingAction1, loadingAction2]).pipe(
  scan((acc, curr) => acc + (curr ? 1 : -1), 0)
);

loading = loadingCount.pipe(
  map(val => val !== 0)
);

loading.pipe(
  prePostDelay(500, 1000)
).subscribe(val => console.log("show loading indicator", val));

Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source
Solution 1
Solution 2 kos
Solution 3
Solution 4