Vue3 reactivity lost when using async operation during object creation

I’m working with some objects (classes) in my TS codebase which perform async operations right after their creation. While everything is working perfectly fine with Vue 2.x (code sample), reactivity breaks with Vue3 (sample) without any errors. The examples are written in JS for the sake of simplicity, but behave the same as my real project in TS.

import { reactive } from "vue";
class AsyncData {
  static Create(promise) {
    const instance = new AsyncData(promise, false);

    instance.awaitPromise();

    return instance;
  }

  constructor(promise, immediate = true) {

    // working, but I'd like to avoid using this
    // in plain TS/JS object
    // this.state = reactive({
    //   result: null,
    //   loading: true,
    // });

    this.result = null;
    this.loading = true;
    this.promise = promise;
    if (immediate) {
      this.awaitPromise();
    }
  }

  async awaitPromise() {
    const result = await this.promise;
    this.result = result;
    this.loading = false;

    // this.state.loading = false;
    // this.state.result = result;
  }
}

const loadStuff = async () => {
  return new Promise((resolve) => {
    setTimeout(() => resolve("stuff"), 2000);
  });
};

export default {
  name: "App",
  data: () => ({
    asyncData: null,
  }),
  created() {
    // awaiting promise right in constructor --- not working
    this.asyncData = new AsyncData(loadStuff());

    // awaiting promise in factory function
    // after instance creation -- not working
    // this.asyncData = AsyncData.Create(loadStuff());

    // calling await in component -- working
    // this.asyncData = new AsyncData(loadStuff(), false);
    // this.asyncData.awaitPromise();
  },
  methods: {
    setAsyncDataResult() {
      this.asyncData.loading = false;
      this.asyncData.result = "Manual data";
    },
  },
};
<div id="app">
    <h3>With async data</h3>
    <button @click="setAsyncDataResult">Set result manually</button>
    <div>
      <template v-if="asyncData.loading">Loading...</template>
      <template v-else>{{ asyncData.result }}</template>
    </div>
</div>

The interesting part is, that the reactivity of the object seems to be completely lost if an async operation is called during its creation.

My samples include:

  • A simple class, performing an async operation in the constructor or in a factory function on creation.
  • A Vue app, which should display “Loading…” while the operation is pending, and the result of the operation once it’s finished.
  • A button to set the loading flag to false, and the result to a static value manually
  • parts commented out to present the other approaches

Observations:

  • If the promise is awaited in the class itself (constructor or factory function), the reactivity of the instance breaks completely, even if you’re setting the data manually (by using the button)
  • The call to awaitPromise happens in the Vue component everything is fine.

An alternative solution I’d like to avoid: If the state of the AsyncData (loading, result) is wrapped in reactive() everything works fine with all 3 approaches, but I’d prefer to avoid mixing Vue’s reactivity into plain objects outside of the view layer of the app.

Please let me know your ideas/explanations, I’m really eager to find out what’s going on 🙂

EDIT: I created another reproduction link, which the same issue, but with a minimal setup: here

Answer

Szia Ábel,

I think the problem you’re seeing might be due to the fact that Vue 3 handles the reactivity differently. In Vue2, the values sent were sort of decorated with additional functionality, whereas in Vue 3, reactivty is done with Proxy objects. As a result, if you do a this.asyncData = new AsyncData(loadStuff());, Vue 3 may replace your reactive object with the response of new AsyncData(loadStuff()) which may loose the reactivity.

You could try using a nested property like

  data: () => ({
    asyncData: {value : null},
  }),
  created() {
    this.asyncData.value = new AsyncData(loadStuff());
  }

This way you’re not replacing the object. Although this seems more complicated, by using Proxies, Vue 3 can get better performance, but loses IE11 compatibility.

If you want to validate the 👆 hypothesis, you can use isReactive(this.asyncData) before and after you make the assignment. In some cases the assignment works without losing reactivity, I haven’t checked with the new Class.


Here’s an alternate solution that doesn’t put reactive into your class

  created() {
    let instance = new AsyncData(loadStuff());
    instance.promise.then((r)=>{
      this.asyncData = {
        instance: instance,
        result: this.asyncData.result,
        loading: this.asyncData.loading,
      }
    });
    this.asyncData = instance;
    // or better yet...
    this.asyncData = {
        result: instance.result,
        loading: instance.loading
    }; 
  }

But it’s not very elegant. It might be better to make the state an object you pass to the class, which should work for vue and non-vue scenarios.

Here’s what that might look like

class withAsyncData {
  static Create(state, promise) {
    const instance = new withAsyncData(state, promise, false);
    instance.awaitPromise();

    return instance;
  }

  constructor(state, promise, immediate = true) {
    this.state = state || {};
    this.state.result = null;
    this.state.loading = true;
    this.promise = promise;
    if (immediate) {
      this.awaitPromise();
    }
  }

  async awaitPromise() {
    const result = await this.promise;
    this.state.result = result;
    this.state.loading = false;
  }
}

const loadStuff = async () => {
  return new Promise((resolve) => {
    setTimeout(() => resolve("stuff"), 2000);
  });
};

var app = Vue.createApp({
  data: () => ({
    asyncData: {},
  }),
  created() {
    new withAsyncData(this.asyncData, loadStuff());
    
    // withAsyncData.Create(this.asyncData, loadStuff());
    
    // let instance = new withAsyncData(this.asyncData, loadStuff(), false);
    // instance.awaitPromise();
  },
  methods: {
    setAsyncDataResult() {
      this.asyncData.loading = false;
      this.asyncData.result = "Manual data";
    },
  },
});

app.mount("#app");
<script src="https://unpkg.com/vue@3.0.11/dist/vue.global.prod.js"></script>
<div id="app">
  <div>
    <h3>With async data</h3>
    <button @click="setAsyncDataResult">Set result manually</button>
    <div>
      <template v-if="asyncData.loading">Loading...</template>
      <template v-else>{{ asyncData.result }}</template>
    </div>
  </div>
</div>
Source: stackoverflow
The answers/resolutions are collected from stackoverflow, are licensed under cc by-sa 2.5 , cc by-sa 3.0 and cc by-sa 4.0 .