'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 |