Photo by Samuel Sianipar on Unsplash.
Today at work I had to create a loading spinner that was shown with a delay, e.g. if an http requests takes more than xx ms to complete, a spinner should be shown, but if it takes less the spinner should not show. Since this was using angular I decided to use rxjs to implement the functionality. Note that this example is using only rxjs, no angular is required and this can be reused in any setup using rxjs.
First we need an observable to toggle whether the spinner should be visible or not, I’ll be using a Subject
for this so we can update the value. This could also be an observable from your preferred type of state management library that uses observables.
const loading$ = new Subject(); // show spinner loading$.next(true); // hide spinner loading$.next(false);
Next we need something to react when the value of loading$
is true
.
loading$.subscribe((loading) => { if (loading) { showSpinner(); } else { hideSpinner(); } });
The above is not yet complete, now it will show and hide the spinner when the loading$
observable emits, but it will do this always, there is no delay yet.
Using the delay
operator
Delaying an emit from an observable is fairly simple, rxjs has a delay
operator for this purpose.
const delayedLoading$ = loading$.pipe(delay(900)); delayedLoading$.subscribe((loading) => { if (loading) { showSpinner(); } else { hideSpinner(); } });
Now we have a delayed observable that emits 900 ms after the loading$
observable emits, but there is a problem – delayedLoading$
is delayed both when loading$
emits true and when it emits false. This effectively means that the spinner will be shown, even if loading emits false before the 900 ms have passed – in this case it will be shown briefly before being hidden again. To only delay showing the spinner we need to cancel the action of showing the spinner. To understand what we want to achieve lets take a look at the implementation using setTimeout
and clearTimeout
.
let timerId; loading$.subscribe((loading) => { if (loading) { // Delay showing the spinner after 900 ms timerId = setTimeout(() => { showSpinner(); }, 900); } else { // Clear the timeout, this will effectively prevent showSpinner from being called, if less than 900 ms has passed. clearTimeout(timerId); // Hide the spinner, in case more than 900 ms has passed, this method should be able to handle being called, even if the spinner is not visible hideSpinner(); } });
The above works, but is not very nice, now we mix observables and setTimeout
and we even introduced a variable outside the scope of the method that handles showing/hiding the spinner.
Actually observables have a way to “cancel” ongoing operations – to cancel an operation with observables basically means that we switch which observable we want to listen to, and rxjs has a switchMap
operator, which maps a value from the source observable to another observable, thus switching between the source observable and the new observable. The switchMap
operator gets the value emitted from the observable and should return an observable. When the observable that is returned emits, the subscribe will be triggered, lets see an example:
const delayedLoading$ = loading$.pipe(switchMap((loading) => { if (loading) { // Create a new observable that emits after 900 ms return of(true).pipe(delay(900)); } // Create a new observable that emits immediately return of(false); })); delayedLoading$.subscribe((loading) => { if (loading) { showSpinner(); } else { hideSpinner(); } });
The above is a lot better than the version using setTimeout
– there is no longer a variable in global scope and we’re only using observables. There is however a minor adjustment we can do, to make it a little simpler. In rxjs there is a conditional operator, iif
, which we can use instead of the if/else clause. It takes a method that returns true or false and based on that it switches between two observables, lets try to use iif
in the example from before.
const delayedLoading$ = loading$.pipe( switchMap((loading) => iif(() => loading, of(loading).pipe(delay(900)), of(loading), ) ) ); delayedLoading$.subscribe((loading) => { if (loading) { showSpinner(); } else { hideSpinner(); } });
Using the debounceTime
operator
There is also another way to make a similar implementation using the debounceTime
operator. The reason I show this last, is because there is a minor issue with it, but for most it might not even be an issue. The implementation is much simpler, but the problem is that both showing and hiding the spinner is debounced, this means that when hiding the spinner the debounce time will be applied before the spinner is hidden. For me I would like to hide it immediately, but for some this might be preferable due to the complexity being less. Lets see an example:
const debouncedLoading$ = loading$.pipe(debounceTime(900)); debouncedLoading$.subscribe((loading) => { if (loading) { showSpinner(); } else { hideSpinner(); } });
Here only the debounceTime
operator is used, there is no switchMap
and no if/else clauses. But as mentioned above, if the spinner is already visible, 900 ms will be added before hideSpinner
is called.
Both approaches solves the problem, but in a slightly different way. Using delay
and switchMap
increases the complexity a bit, but allows for more control, while using the debounceTime
operator is a lot simpler, but less flexible. Choose whichever fits your requirements.