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