4

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

Ábel Énekes
  • 237
  • 1
  • 10
  • Please consider posting some code so other people could know what is happening without visiting any external link. – Roberto Langarica May 07 '21 at 19:25
  • Can you elaborate on _working, but I'd like to avoid using this in plain TS/JS object_. Is it that you want the class to be useable event without Vue? That handling of data has to go somewhere, so the elternative might be that there is an abstract function and then a Vue version that handles the state object, or you could use a way of dealing with it like a pure effect, so there would be a Vue listener triggered after the change. – Daniel May 07 '21 at 20:08

2 Answers2

2

I visited the code sample you posted and it it is working, I observed this:

  • You have a vue component that instantiates an object on its create hook.
  • The instantiated object has an internal state
  • You use that state in the vue component to render something.

it looks something like this:

<template>
<main>
    <div v-if="myObject.internalState.loading"/>
      loading
    </div>
    <div v-else>
      not loading {{myObject.internalState.data}}
    </div>
  </main>
</template>

<script lang="ts">
import { defineComponent } from 'vue';

export default defineComponent({
  name: 'App',
  data(){
    return {
      myObject:null
    }
  },
  created(){
    this.myObject = new ObjectWithInternalState()
  },
});
</script>

ObjectWithInternalState is doing an async operation when instantiated and changing its internalState but when internalState is a plain object then nothing is reactive. This is the expected behavior since changing any internal value of internalState is not a mutation on myObject (vue reactive value), but if instead of using a plain object for internalState yo use a reactive object (using the composition API) and since you are accessing that value on the template then all the changes made to that object are observed by the template (reactivity!!). If you don't want to have mixed things then you need to wait for the async operation in the component.

export default defineComponent({
  name: 'App',
  data(){
    return {
      remoteData:null,
      loading:false
    }
  },
  created(){
    this.loading = true
    // Option 1: Wait for the promise (could be also async/await
    new ObjectWithInternalState().promise
      .then((result)=>{
        this.loading = false
        this.remoteData = result
      })

    // Option 2: A callback
    new ObjectWithInternalState(this.asyncFinished.bind(this))
  },
  methods:{
    asyncFinished(result){
      this.loading = false
      this.remoteData = result
    }
  }
});

My recommendation is to move all state management to a store, take a look at Vuex It is the best practice for what are you intending

  • Thanks for the answer, but I think something else lies behind the problem. If you change my awaitPromise call in AsyncData not to actually await a promise, but assign a dummy value to the result directly (this.result = "something";) then it works. But as soon as a promise is awaited (even if the result is not assigned to an internal variable), the reactivity of the whoel object is broken, – Ábel Énekes May 08 '21 at 06:39
2

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>
Daniel
  • 34,125
  • 17
  • 102
  • 150
  • Hi Daniel, thanks for the answer! Could you maybe explain "As a result, is you do a this.asyncData = new AsyncData(loadStuff());, vue 3 will replace your reactive object with new AsyncData(loadStuff()) which is not reactive." with a little more details? I also tried making the new async data object reactive explicitely by using this.asyncData = reactive(new AsyncData(loadStuff())); but it doesn't seem to change the behaviour. – Ábel Énekes May 08 '21 at 06:54
  • When a Proxy is created in js you pass two parameters, the target and the handler. The resulting Proxy is reactive, in the sense that it can trigger the `set` method in the handler. So when you change a property of the _Proxy_, the `set` function will trigger. The `target` that you pass, however, does not become reactive as a result, so **changing a value of the target does not trigger the `set method** so modifying the values of the Class cannot trigger reactivity. – Daniel May 09 '21 at 04:31
  • In short, the values are being changed within the target, and the target is not reactive. Only the Proxy created from the target is reactive. – Daniel May 09 '21 at 04:32
  • Thank you for the detailed explanation, now its clear. I also opened an issue on github, where the same thing was confirmed: https://github.com/vuejs/vue-next/issues/3743 – Ábel Énekes May 09 '21 at 07:19