Vue 3 vs Vue 2 reactivity with Composition API

We have been using the new Composition API with Vue 2 for some time now and recently experimented with porting some projects to Vue 3. However we have a significant issue with the changes to reactivity in Vue 3.

The fundamental issue we have with the re-write of reactivity in Vue 3 is that changes to raw objects are not tracked - only changes to the proxies generated by ref/reactive are now tracked. This means the same code in Vue 2 with Compoistion API does not work the same in Vue 3.

With Vue 2 reactivity, it was an excellent feature that you could have objects created by business logic shared between Vue components and when the business logic changed properties of these objects, the UI reacted accordingly. However, with Vue 3, this no longer works unless you generate a proxy using ref/reactive in the UI code and somehow pass that proxy back to the business logic layer (or get Vue to wrap the object in a proxy within the business logic code which is otherwise independent/unaware of Vue).

As a simple example, the following Vue component works completely differently in Vue 2 and Vue 3.

<template>
  <div>
    <button v-on:click="onClick('raw')">rawObj</button>
    <button v-on:click="onClick('ref1')">ref1</button>
    <button v-on:click="onClick('ref2')">ref2</button>
    <button v-on:click="onClick('reactive')">reactiveObj</button>
    <div>objRef1: {{ref1.counter}}</div>
    <div>objRef2: {{ref2.counter}}</div>
    <div>reactiveObj: {{reactiveObj.obj.counter}}</div>
  </div>
</template>

<script lang="ts">

// Vue 2
import { defineComponent, ref, reactive, watch } from '@vue/composition-api';
// Vue 3
// import { defineComponent, ref, reactive, watch } from 'vue'

export default defineComponent({
  name: 'CodeSample',
  setup() {
    const rawObject = {
      counter: 1
    }

    const ref1 = ref(rawObject);
    const ref2 = ref(rawObject);
    const reactiveObj = reactive({
      obj: rawObject
    });

    watch(
      () => rawObject.counter,
      (newValue,oldValue) => {
        console.log('watch raw', `${oldValue} -> ${newValue}`);
      }
    );

    watch(
      () => ref1.value.counter,
      (newValue,oldValue) => {
        console.log('watch ref1', `${oldValue} -> ${newValue}`);
      }
    );

    function onClick(what:string) {
      switch (what) {
        case 'raw':
          rawObject.counter++;
          break;

        case 'ref1':
          ref1.value.counter++;
          break;

        case 'ref2':
          ref2.value.counter++;
          break;

        case 'reactive':
          reactiveObj.obj.counter++;
          break;
      }

      console.log('raw', rawObject.counter);
      console.log('ref1', ref1.value.counter);
      console.log('ref2', ref2.value.counter);
      console.log('reactive', reactiveObj.obj.counter);
    }

    return {
      ref1,
      ref2,
      reactiveObj,
      onClick,
    }
  }
});

</script>

In Vue 2, the raw object used to create the two refs and the reactive object can be manipulated and everything reacts including both watches as can be seen by clicking the first button.

In Vue 3, manipulating the raw object does change the value in the proxies but none of the reactivity works including the raw watch. However, modifying either of the refs or the reactive object works the same as with Vue 2.

For me, this is a big backwards step because essentially with Vue 3, only objects within the UI code can be reactive - objects from non-UI code (business logic) cannot be updated outside of the UI without polluting the non-UI code.

In addition, we have found that the proxy mechanism used by Vue 3 reactivity can break business logic objects that themselves already had some form of proxying whereas Vue.Observable from Vue 2 worked and didn’t break anything. In particular, we have projects using BreezeJS which creates entity objects for records in a database and these entity objects break when made reactive by Vue 3 and are no longer able to track property changes themselves (i.e. they only work if the properties are changed on the raw unproxied objects).

Finally, the fact that watch only works for reactive objects and their properties means there is no easy way round this problem because you cant create a watch on a property of the unwrapped object to pick up changes made outside the UI code.

What might be good would be the option to use Vue 2’s Observable as an alternative for proxied refs in Vue 3 (e.g. an observableRef function to create a reactive object using Vue 2’s mechanism).

Having data managed by code that wasn’t Vue aware was always discouraged due to the potential for problems. Sometimes it would work, sometimes it wouldn’t, depending on whether it happened to expose things in a way that played nice with Vue. Internal objects or values hidden within closures are inaccessible to the reactivity system. The reactivity caveats are a likely source of problems for code that isn’t written with Vue in mind.

All that said…

If you have code that worked with Vue 2 then you could probably get it working with Vue by rewriting the objects to use get/set, mimicking Vue 2. Internally those could use ref to apply the reactivity.

Here’s a crude demo of how that might work:

https://jsfiddle.net/skirtle/5geanrLu/1/

It’d need more work to accurately recreate the Vue 2 experience. I’ve ignored arrays completely, though that may not be as big a problem as it first appears. The internal use of ref should add proxies to any nested objects/arrays, so they’ll be reactive so long as they’re accessed via that main object. That could be a problem for any code relying on object equality. I’ve added some basic recursion, though that probably isn’t necessary because the ref will recursively wrap everything in proxies anyway.

Thanks for the pointers.

I have experimented with replacing the properties with get/set using a Ref behind the scenes to store the value. It then seems that when Vue generates a Proxy, these Ref properties get unwrapped so you need unref() in the get method and in the set method, you need to test with isRef() to determine whether to assign the new value to .value or direct. This is a bit strange because you are not supposed to be able tell whether an object is a proxy or not according to the JS spec. but it seems with a Vue 3 generated proxy, you can just check to see if a Ref property has been unwrapped.

I managed to get my reactive objects working by having a backing store object for the property values and wrapping it in reactive() rather than using refs. I was then able to manipulate the object outside of the Vue UI code and the UI reacted accordingly.

However, in experimenting with this, it is clear that Vue 3 reactivity is doing some strange things. In my opinion, when it proxies an object to make it reactive, it shouldn’t make any significant changes to that object. However, if you have a property of the object that is a ref, Vue 3 will unwrap it when it generates a proxy.

For TypeScript classes, this is particularly a problem because if you have a property typed as Ref, it can become unwrapped and the internal code within the class has to test whether it is wrapped or unwrapped.

We have found other examples of where Vue 3 reactivity has broken or changed the functionality of a class it has made reactive but these are harder to isolate to post here with examples.

We were hoping to begin the process of migrating our Vue 2 apps and to use Vue 3 for new projects but given these issues and the problems we are having with performance of the hot reload process in Vue 3, we will be pausing this for a while.

Performance (although we can’t see much difference) and array manipulation aside, bring back Vue 2 Observables!!!