How to prevent aborted async requests from corrupting state with watchEffect()

Viewed 189

Say we have a component that receives the ID of a resource through a prop, resourceId. The component should fetch the corresponding resource from an API and display it, while also handling loading/error states (following a pattern similar to this one).

The function that fetches the resource from the external API also returns an abort function which, if called, causes the request to immediately reject. When resourceId changes, any in-flight requests should be aborted in favor of a new request. For what it's worth, I'm using fetch() and AbortController for this.

Using Vue 3 with the composition API, I came up with an implementation that looks something like this:

const loading = ref(false);
const data = ref(null);
const error = ref(null);

watchEffect(async (onCancel) => {
    loading.value = true;
    data.value = error.value = null;

    const { response, abort } = fetchResourceFromApi(props.resourceId);
    onCancel(abort);

    try {
        data.value = await (await response).json();
    } catch (e) {
        error.value = e;
    } finally {
        loading.value = false;
    }
});
<div v-if="loading">Loading...</div>
<div v-else-if="error">Error: {{ error }}</div>
<div v-else>{{ data }}</div>

This works fine under most circumstances, but breaks whenever the cancellation comes in to play. If resourceId changes before the last API request has finished, the following order of events happens:

  1. abort() gets called
  2. watchEffect callback gets called, setting loading, error, and data
  3. catch and finally blocks from original request are called, setting loading and error
  4. Second API request completes and sets loading and data

This results in an unexpected state where loading is set to false while the second request is in flight, error contains the exception raised by aborting the first request, and data contains the value from the second request.

Are there any design patterns or workarounds that can deal with this problem?

1 Answers

if the sequence of events is as you described:

  1. first watchEffect
  2. abort
  3. second watchEffect
  4. catch and finally of the first watchEffect

Then you can keep a counter and use it to track what the current in flight request is (effectively it is a request id, or version as some libraries call it). Every time watchEffect is called, increase the counter and take a snapshot of the current value. Check in your catch and finally block if the present counter is the same as the counter value you started with. In case they mismatch, it means you are handling stale request errors so you can skip the modification of error and loading status.

const {ref, watchEffect} = Vue;

const App = {
  setup() {
    const resourceId = ref(0);
    const loading = ref(false);
    const data = ref(null);
    const error = ref(null);

    // fake an api fetch method to asynchronously return 
    // a result after 3 seconds.
    const fetchFor = (id) => {
      let abort;
      const response = new Promise((resolve, reject) => {
        abort = () => reject(`request ${id} aborted`);
        setTimeout(() => resolve({msg: "result for " + id}), 3000);
      });
      return {response, abort};
    };


    let version = 0;

    watchEffect(async (onCancel) => {
      version ++;
      const currentVersion = version;
      
      loading.value = true;
      data.value = error.value = null;
      const { response, abort } = fetchFor(resourceId.value);
      onCancel(abort);
      try {
        data.value = await response;
      } catch (e) {
        if (currentVersion === version) {
          error.value = e;
        }
        
      } finally {
        if (currentVersion === version) {
          loading.value = false;
        }
      }
    });

    return {resourceId, loading, data, error};
  }
};
   
const app = Vue.createApp(App);
app.mount("#app");
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>

<div id="app">
 <button @click="resourceId++">Request</button>
 <div> last request is {{resourceId}} </div>
 <div> loading = {{loading}} </div>
 <div> data = {{data?.msg}} </div>
 <div> error = {{error}} </div>
</div>

Related