Showing a loading spinner delayed with rxjs

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.