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>
CodePudding user response:
Observables are heavily used in Angular, both in the ecosystem and framework itself. When there's no good use case for them, it's beneficial to stick to promises for asynchronous code.
Asynchronous side effects go to lifecycle hooks instead of setup body:
onMounted(async () => {
const dataPromise = getData();
let result = await Promise.race([delay(100), dataPromise])
if (!result) {
loading.value = true;
// not awaited
Promise.all([delay(250), dataPromise]
.finally(() => loading.value = false);
result = await dataPromise;
}
data.value = result;
});
The loader itself is easily implemented with Vue suspense feature, this requires to split the component into 2, the parent hosts tag, and the child does asynchronous work with script setup
and await
CodePudding user response:
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);
const timeLogs = reactive([]);
onMounted(async () => {
const current = new Date();
const timeLogEntry = initialiseLogTime(current);
timeLogEntry('Start requesting data')
const p = getData();
timeLogEntry('Race starts')
const res = await Promise.race([delay(100), p])
if (!res) {
timeLogEntry('Delay wins race')
timeLogEntry('Display Loading')
loading.value = true;
// wait at least 200ms
await delay(250);
// wait for promise to resolve
const d = await p;
timeLogEntry('Stop Loading and Display Data')
// assign data for UI to display
loading.value = false;
data.value = d;
} else {
timeLogEntry('Data is wins race')
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})
});
}
function initialiseLogTime(current){
return function(str){
timeLogs.push((new Date() - current) ' ms: ' str )
}
}
</script>
<template>
<h1 v-if="loading">
Loading...
</h1>
<h1 v-if="!loading && data">
Data
</h1>
<p v-for="log in timeLogs">
{{log}}
</p>
</template>