Search code examples
javascriptvue.jsrxjsreactive-programming

Vue Reactivity vs Angular RxJS: how to handle Async UI updates that depends on timers


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:

Link to Vue Playground

<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>

Solution

  • 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>