Issue
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
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/rxjs@6.5.2/bundles/rxjs.umd.min.js"></script>
Hope this helps
Answered By - kos
0 comments:
Post a Comment
Note: Only a member of this blog may post a comment.