Ayrton
Ayrton

Reputation: 2303

Updates to object inside array do not trigger updates

In my root Vue instance, I have an array of objects with some data, which I use to render a set of components. These components have a watcher on the object of data provided to them, which is supposed to make an asynchronous call every time the object is updated.

The problem is that when I update a property of one of the objects in my array, the watcher is not called. It shouldn't fall into any of Vue's caveats because a) I'm not adding a new property, just updating an existing one and b) I'm not mutating the array itself in any way. So why is this happening? And how do I fix it?

My main Vue instance:

let content = new Vue({
    el: '#content',
    data: {
        testData: [
            { name: 'test1', params: {
                testParam: 1
            } },
            { name: 'test2', params: {
                testParam: 1
            } },
            { name: 'test3', params: {
                testParam: 1
            } }
        ]
    }
});

The code which I use to render my components:

<div id="content">
    <div v-for="item in testData">
        <test-component v-bind="item"></test-component>
    </div>
</div>

And my component:

Vue.component('test-component', {
    props: {
        name: {
            type: String,
            required: true
        },
        params: {
            type: Object,
            required: true
        }
    },
    data: function() {
        return { asyncResult: 0 };
    },
    watch: {
        params: function(newParams, oldParams) {
            // I use a custom function to compare objects, but that's not the issue since it isn't even being called.

            console.log(newParams);

            if(!this.compareObjs(newParams, oldParams)) {
                // My async call, which mutates asyncResult
            }
        }
    },
    template: `
        <span>{{ asyncResult }}</span>
    `
});

My goal is to mutate the properties of the params property of a given object and trigger the watcher to rerender the corresponding component, but when I try to mutate it directly it doesn't work.

Example (and the way I'd like my component to work):

content.testData[2].params.testParam = 5;

Unfortunately, it doesn't. Using Vue.set doesn't work either:

Vue.set(content.testData[2].params, 'testParam', 5);

The only thing I found which does work is to assign a new object entirely (which is not something I'd like to do every time I have to mutate a property):

content.testData[2].params = Object.assign({}, content.testData[2].params, { testParam: 5 });

I also tried using a deep watcher, as suggested in a similar question, but it didn't work in my case. When I use the deep watcher the function is called, but both newParams and oldParams are always the same object, no matter which value I set to my property.

Is there a solution to this that will allow me to mutate the array items just by setting a property? That would be the most desirable outcome.

Upvotes: 0

Views: 3460

Answers (2)

skirtle
skirtle

Reputation: 29092

First things first.

Using Vue.set isn't going to help. Vue.set is used to set the values of properties that Vue's reactivity system can't track. That includes updating arrays by index or adding new properties to an object but neither of those apply here. You're updating an existing property of a reactive object, so using Vue.set won't do anything more than setting it using =.

Next...

Vue does not take copies of your objects when passing them as props. If you pass an object as a prop then the child component will get a reference to the same object as the parent. A deep watcher will trigger if you update a property within that object but it's still the same object. The old and new values passed to the watcher will be the same object. This is noted in the documentation:

https://v2.vuejs.org/v2/api/#vm-watch

Note: when mutating (rather than replacing) an Object or an Array, the old value will be the same as new value because they reference the same Object/Array. Vue doesn’t keep a copy of the pre-mutate value.

As you've noticed, one solution is to use a totally new object when performing the update. Ultimately, if you want to compare the old and new objects then you have no choice but to make a copy of the object somewhere. Taking a copy when mutating is a perfectly valid choice, but it's not the only option.

Another option would be to use a computed property to create the copy:

new Vue({
  el: '#app',
  
  data () {
    return {
      params: {
        name: 'Lisa',
        id: 5,
        age: 27
      }
    }
  },
  
  computed: {
    watchableParams () {
      return {...this.params}
    }
  },
  
  watch: {
    watchableParams (newParams, oldParams) {
      console.log(newParams, oldParams)
    }
  }
})
<script src="https://unpkg.com/[email protected]/dist/vue.js"></script>
<div id="app">
  <input v-model="params.name">
  <input v-model="params.id">
  <input v-model="params.age">
</div>

A few notes on this:

  1. The computed property in this example is only creating a shallow copy. If you needed a deep copy it would be more complicated, something like JSON.stringify/JSON.parse might be an option.
  2. The computed property doesn't actually have to copy everything. If you only want to watch a subset of the properties then only copy those.
  3. The watch doesn't need to be deep. The computed property will create dependencies on the properties it uses and if any of them changes it will be recomputed, creating a new object each time. We just need to watch that object.
  4. Vue caches the values of computed properties. When a dependency changes the old value is marked as stale but it isn't immediately discarded, so that it can be passed to watchers.

The key advantage of this approach is where the copying is handled. The code doing the mutating doesn't need to worry about it, the copying is performed by the same component that needs the copy.

Upvotes: 2

Matheus Valenza
Matheus Valenza

Reputation: 919

As you said, you will need to use deep property in watch.

Using Vue.set you should remounting the entire object inside your array, like:

const newObj = {
  name: 'test1',
  params: {
    testParam: 1,
  },
};

Vue.set(yourArray, newObj, yourIndex);

Note you are setting some value inside your array and in this case the array contains objects.

Upvotes: 0

Related Questions