How would you display a loading spinner, following an HTTP request (or any other async operation that happens over a period of time), based on the following logic?
wait X time (=100ms) and display nothing
if after X time (=100ms) the data arrived, display the data immediately.
if after X time (=100ms) the data has not arrived, display the spinner for at least Y time (=250ms), and until the data arrives.
In Angular/RxJS you would do something like this in your Service:
/**
* We want to display the loading indicator only if the requests takes more than `${INITIAL_WAITING_TIME}
* if it does, we want to wait at least `${MINIMUM_TIME_TO_DISPLAY_LOADER} before emitting
*/
const startLoading$ = of({}).pipe(
tap(() => this.setState({ loading: true })),
delay(MINIMUM_TIME_TO_DISPLAY_LOADER),
switchMap(() => EMPTY)
);
const hideLoading$ = of(null).pipe(
tap(() => this.setState({ loading: false }))
);
const timer$ = timer(INITIAL_WAITING_TIME).pipe();
/**
* We want to race two streams:
*
* - initial waiting time: the time we want to hold on any UI updates
* to wait for the API to get back to us
*
* - data: the response from the API.
*
* Scenario A: API comes back before the initial waiting time
*
* We avoid displaying the loading spinner altogether, and instead we directly update
* the state with the new data.
*
* Scenario B: API doesn't come back before initial waiting time.
*
* We want to display the loading spinner, and to avoid awkward flash (for example the response comes back 10ms after the initial waiting time) we extend the delay to 250ms
* to give the user the time to understand the actions happening on the screen.
*/
const race$ = race(timer$, data$).pipe(
switchMap((winner) =>
typeof winner === 'number' ? startLoading$ : EMPTY
)
);
return concat(race$, hideLoading$, data$).pipe(filter(Boolean));
In Vue, I could not find a better way than using setTimeout
and nesting watch
to react to changes to a reactive property:
<script setup lang="ts">
import { ref, watch } from 'vue'
const displayUI = ref<boolean>(false);
const loading = ref<boolean>(false);
const data = ref<string | null>(null);
setTimeout(() => {
// after 100ms we want to display a UI to the user
displayUI.value = true;
// if data has arrived, we can display and exit this logic
if (data.value) {
return;
}
// if it has not arrived
// we show spinner for at least 250ms
loading.value = true;
setTimeout(() => {
// at this point, we should display data, but only after data has arrived
// Question is: without RxJS, how can we
if (data.value) {
loading.value = false;
} else {
// can we nest a watcher?
watch(data, (value) => {
if (value) {
loading.value = false
data.value = 'it worked!'
}
})
}
}, 2500)
}, 1000)
// fake timer, let's say our API request takes X amount of time to come back
setTimeout(() => {
data.value = 'Data arrived'
}, 4000)
</script>
<template>
<template v-if="displayUI">
<h1 v-if="!data && !loading">
No Data
</h1>
<h1 v-if="!loading && data">
{{ data }}
</h1>
<h1 v-if="loading">
Loading...
</h1>
</template>
</template>
Thanks to @Etus Flask for his answer that set me up in the right direction. Having used Observable for this type of logic, I never actually used Promise.race
and completely forgot about its existance.
I created an example that can hopefully also help others (Vue Playground) and contains the feature fully implemented.
I added some time logs so you can see the timeline of the different steps.
<script setup>
import { ref, onMounted, reactive } from 'vue';
const loading = ref(false)
const data = ref(null);
onMounted(async () => {
const p = getData();
const res = await Promise.race([delay(100), p])
if (!res) {
loading.value = true;
await delay(250);
const d = await p;
// assign data for UI to display
loading.value = false;
data.value = d;
} else {
data.value = res;
}
})
async function delay(time){
return new Promise((res) => {
setTimeout(res, time)
})
}
// we simulate a network request
async function getData() {
return new Promise(res => {
setTimeout(res, 200, {data: true})
});
}
</script>
<template>
<h1 v-if="loading">
Loading...
</h1>
<h1 v-if="!loading && data">
Data
</h1>
</template>