Vue component's prop set to instance data mutates the upstream value - javascript

I am seeing some weird behaviour here that was unexpected, but it makes intuitive-sense to me in terms of pure JavaScript.
I have a form controller that accumulates a this.thing object that is sent to the server on the final submit. It's a multi-step form, so each step adds some data to this.thing.
So the controller has:
data() {
return {
thing: {},
};
},
The DOM markup for this controller has a child like:
<a-child
:initial-thing="thing"
></a-child>
The child uses that prop to display its initial state, so it receives the prop and sets it into its own local state as instance data:
initialThing: {
type: Object,
required: true,
},
...
data() {
return {
thing: this.initialThing,
};
},
Then this child has a checkbox that is like this:
<a-checkbox
v-model="thing.field"
:initial-value="initialThing.field"
></a-checkbox>
This all works fine, except I just noticed that when the checkbox changes, it's mutating the parent controllers thing.field value.
I'm making this question because I don't understand how Vue can do that, and the only thing that makes sense to me is that when the child does thing: this.initialThing, it's allowing the child to call the setter function on that field on this.initialThing.
It stops mutating the parent's state if I do this instead:
data() {
return {
thing: { ...this.initialThing },
};
},
In my actual app, it's more complex because there are 2 intermediate components, so the grandchild is mutating the grandparent's state, and it stems from the pattern I am describing here.
Can anyone provide a kind of textbook answer for what is happening here? I'm hesitant to rely on this behaviour because the code driving it is not explicit. It makes some of my $emit() events redundant in favour of using this indirect/non-explicit way of sending data upstream.
Also to be clear, this has nothing to do with v-model because it also does it if I do this.thing.field = 'new value';. I believe it has everything to do with inheriting the getters/setters on this.initialThing. Is it safe to rely on this behaviour? If I rely on it, it will make my code more concise, but a naive individual may have a hard time understanding how data is making it into the grandparent component.

This is a shallow copy so you can't prevent mutating grandchildren.
data() {
return {
thing: { ...this.initialThing },
};
},
The solution is below:
data() {
return {
thing: JSON.parse(JSON.stringify(this.initialThing)),
};
},
const initialThing = {
age: 23,
name: {
first: "David",
last: "Collins",
}
}
const shallowCopy = { ...initialThing };
shallowCopy.age = 10;
shallowCopy.name.first = "Antonio"; // will mutate initialThing
console.log("init:", initialThing);
console.log("shallow:", shallowCopy);
const deepCopy = JSON.parse(JSON.stringify(initialThing));
deepCopy.age = 30;
shallowCopy.first = "Nicholas"; // will not mutate initialThing
console.log("------Deep Copy------");
console.log("init:", initialThing);
console.log("deep:", deepCopy);
How it works:
JSON.stringify(this.initialThing)
This converts JSON Object into String type. That means it will never mutate children anymore.
Then JSON.parse will convert String into Object type.
But, using stringify and parse will be expensive in performance. :D
UPDATED:
If you are using lodash or it is okay to add external library, you can use _.cloneDeep.
_.cloneDeep(value); // deep clone
_.clone(value); // shallow clone

Related

What's the best practice for adding an Object to a React hooks (useEffect, useMemo) dependancy array?

When you need to add a large object to a react hooks (useEffect, useMemo, useCallback) dependency array, what's the best practice when doing so.
let largeDeepObject = {
parent: {
child: { ... },
},
};
useEffect(() => {
// do something
}, [largeDeepObject]);
const memoizedValue = useMemo(() => {
// do something
}, [largeDeepObject]);
React uses Object.is() for array dependency comparison which will return false on objects without a reference to the original value. This will re-render the hook even when the object hasn't changed.
const foo = { a: 1 };
const bar = { a: 1 };
const sameFoo = foo;
Object.is(foo, foo); // true
Object.is(foo, bar); // false
Object.is(foo, sameFoo); // true
It would be best to reference strictly the values that you actually use in the effect, as granular as possible. Many times these will be primitives which means that you don't even have to worry about them unnecessarily changing.
If you do have to reference objects or other structures such as arrays then you need to make sure their reference stays the same. You should always be aware why your references change and prevent this when not necessary. Besides helping your effects work seamlessly it will most probably also positively impact your app performance.
Don't forget to extract your variables outside the component whenever possible (i.e. when not using props or state). This way you will not need to add them as dependencies.
Also, using memoization up the components chain is always an option (useMemo, useCallback and soon useEvent), just don't overuse it unnecessarily! :)

Vue not updating global state

I have some problems with updating the global state. I'm trying to update that state by listening WebSocket, but it's not updating as expected.
Here is how did I define the global state.
state: {
userData: null
},
getters: {
userData: state => {
return state.userData
},
mutations: {
GET_USER({payload}) {
commit('GET_USER', payload)
},
And I'm updating it in App.vue like so:
mounted() {
window.Echo.channel("user." + this.userData.id).listen(".user-updated", (user) => {
this.$store.commit('GET_USER', JSON.stringify(user.user))
});
Ofcourse I'm closing that websocket. I tried with localStorage, which I think is not a bad idea, but still I'm doing it with global state, and with localstorage would look like:
localStorage.setItem('userData', JSON.stringify(user.user))
So when I want to show that data in some component, for example, Home.vue, the Only way that I can see what is happening, is by defining {{ this.$store.getters.userData }} in the template of that file, but If I try to define it in scripts data, like so:
data() {
return {
data: this.$store.getters.userData,
}
},
It's not updating real time, but only if I go to another page and return here, or update the component.
Any ideas on how to fix it?
I had success by accessing the state. As far as I understand it it should be reactive as well this.$store.state.userData and reflect the current state of the store just as well.
Keep in mind accessing object properties might not be reactive the way you think they are: https://v2.vuejs.org/v2/guide/reactivity.html#For-Objects
So you probably want to define a getter where you access the user's name, credentials, whatever userData holds.
The more explicit approach is defining every property of the userData object in the store and write a mutation for every property individually (loops are possible to do that at once, of course, but you'll have to create the mutators still).
{
state: {
userData: {
id: null,
name: '',
email: '',
// etc.
}
},
// ...
}
Doing it this way may also work successfully with the getter you defined.
For my work I would rather not trust handling whole objects at once. This is a bit of extra code to write but explicitly defining which properties the expected object should have is valuable to you and others.

Add watched object to array - Vue

I am in middle of vue application.
The problem I am facing is that I want to add only the changed/unique object to the new array.
It keeps on adding repeated objects. I am sure it's some kind of silly mistake on my side, but I can't seem to find it.
<script>
export default {
data() {
return {
changedArray: [],
originalArray: [
{key1:val1},
{key1:val2},
{key1:val3}
]
};
},
created() {
this.originalArray.forEach((val) => {
this.$watch(() => val, this.handleChange, { deep: true });
});
},
methods: {
handleChange(newVal) {
if (this.changedArray.length > 0) {
this.changedArray.forEach((o) => {
if (o.key1 !== newVal.key1) {
this.changedArray.push(newVal)
}
});
} else {
this.changedArray.push(newVal)
}
},
}
};
</script>
VueJS can have some issues with the reactivity of deep watching objects inside arrays like this.
First of all, consider if you can remove the need to use a watcher. In most cases, watching an object is not necessary and can be avoided by changing the structure of the program.
This is preferred as it reduces component complexity and keeps things clear. Similarly, watchers can be computationally complex for vue, especially when they are deep.
In your example, you are creating a watcher for each item of an array. This could become incredibly detrimental to your performance
Is there any way you can refactor so that the method that is responsible for updating each of the individual objects also runs the handleChange comparison? That way no watcher is required.
If you must use this approach, you will need to separate your logic by creating a child component that houses the watcher logic.
That way instead of looping over each object in the parent component and creating a watcher, the v-for in the parent component creates instances of the child component.
The child component then creates a watcher for itself only, and emits an event to the parent when it is updated.

Vue Reactivity: Why replacing an object's property (array) does not trigger update

I have a Vue app where I'm trying to make a thin wrapper over the Mapbox API. I have a component which has some simple geojson data, and when that data is updated I want to call a render function on the map to update the map with that new data. A Vue watcher should be able to accomplish this. However, my watcher isn't called when the data changes and I suspect that this is one of the cases that vue reactivity can't catch. I'm aware that I can easily fix this problem using this.$set, but I'm curious as to why this isn't a reactive update, even though according to my understanding of the rules it should be. Here's the relevant data model:
data() {
return{
activeDestinationData: {
type: "FeatureCollection",
features: []
}
}
}
Then I have a watcher:
watch: {
activeDestinationData(newValue) {
console.log("Active destination updated");
if (this.map && this.map.getSource("activeDestinations")) {
this.map.getSource("activeDestinations").setData(newValue);
}
},
}
Finally, down in my app logic, I update the features on the activeDestination by completely reassigning the array to a new array with one item:
// Feature is a previously declared single feature
this.activeDestinationData.features = [feature];
For some reason the watcher is never called. I read about some of the reactivity "gotchas" here but neither of the two cases apply here:
Vue cannot detect the following changes to an array:
When you directly set an item with the index, e.g. vm.items[indexOfItem] = newValue
When you modify the length of the array, e.g. vm.items.length = newLength
What am I missing here that's causing the reactivity to not occur? And is my only option for intended behavior this.set() or is there a more elegant solution?
as default vue will do a shallow compare, and since you are mutating the array rather than replacing, its reference value is the same. you need to pass a new array reference when updating its content, or pass the option deep: true to look into nested values changes as:
watch: {
activeDestinationData: {
handler(newValue) {
console.log("Active destination updated");
if (this.map && this.map.getSource("activeDestinations")) {
this.map.getSource("activeDestinations").setData(newValue);
}
},
deep: true
}
}
If you need to watch a deep structure, you must write some params
watch: {
activeDestinationData: {
deep: true,
handler() { /* code... */ }
}
You can read more there -> https://v2.vuejs.org/v2/api/#watch
I hope I helped you :)

How to induce reactivity when updating multiple props in an object using VueJS?

I was witnessing some odd behaviour while building my app where a part of the dom wasn't reacting properly to input. The mutations were being registered, the state was changing, but the prop in the DOM wasn't. I noticed that when I went back, edited one new blank line in the html, came back and it was now displaying the new props. But I would have to edit, save, the document then return to also see any new changes to the state.
So the state was being updated, but Vue wasn't reacting to the change. Here's why I think why: https://v2.vuejs.org/v2/guide/reactivity.html#For-Objects
Vue cannot detect property addition or deletion. Since Vue performs the getter/setter conversion process during instance initialization, a property must be present in the data object in order for Vue to convert it and make it reactive
Sometimes you may want to assign a number of properties to an existing object, for example using Object.assign() or _.extend(). However, new properties added to the object will not trigger changes. In such cases, create a fresh object with properties from both the original object and the mixin object
The Object in my state is an instance of js-libp2p. Periodically whenever the libp2p instance does something I need to update the object in my state. I was doing this by executing a mutation
syncNode(state, libp2p) {
state.p2pNode = libp2p
}
Where libp2p is the current instance of the object I'm trying to get the DOM to react to by changing state.p2pNode. I can't use $set, that is for single value edits, and I think .assign or .extend will not work either as I am trying to replace the entire object tree.
Why is there this limitation and is there a solution for this particular problem?
The only thing needed to reassign a Vuex state item that way is to have declared it beforehand.
It's irrelevant whether that item is an object or any other variable type, even if overwriting the entire value. This is not the same as the reactivity caveat situations where set is required because Vue can't detect an object property mutation, despite the fact that state is an object. This is unnecessary:
Vue.set(state, 'p2pNode', libp2p);
There must be some other problem if there is a component correctly using p2pNode that is not reacting to the reassignment. Confirm that you declared/initialized it in Vuex initial state:
state: {
p2pNode: null // or whatever initialization value makes the most sense
}
Here is a demo for proof. It's likely that the problem is that you haven't used the Vuex value in some reactive way.
I believe your issue is more complex than the basic rules about assignment of new properties. But the first half of this answer addresses the basics rules.
And to answer why Vue has some restrictions about how to correctly assign new properties to a reactive object, it likely has to do with performance and limitations of the language. Theoretically, Vue could constantly traverse its reactive objects searching for new properties, but performance would be probably be terrible.
For what it's worth, Vue 3's new compiler will supposedly able to handle this more easily. Until then, the docs you linked to supply the correct solution (see example below) for most cases.
var app = new Vue({
el: "#app",
data() {
return {
foo: {
person: {
firstName: "Evan"
}
}
};
},
methods: {
syncData() {
// Does not work
// this.foo.occupation = 'coder';
// Does work (foo is already reactive)
this.foo = {
person: {
firstName: "Evan"
},
occupation: 'Coder'
};
// Also works (better when you need to supply a
// bunch of new props but keep the old props too)
// this.foo = Object.assign({}, this.foo, {
// occupation: 'Coder',
// });
}
}
});
<script src="https://cdnjs.cloudflare.com/ajax/libs/vue/2.5.17/vue.js"></script>
<div id="app">
Hello {{foo.person.firstName}} {{foo.occupation}}!
<button #click="syncData">Load new data</button>
</div>
Update: Dan's answer was good - probably better than mine for most cases, since it accounts for Vuex. Given that your code is still not working when you use his solution, I suspect that p2pNode is sometimes mutating itself (Vuex expects all mutations in that object to go through an official commit). Given that it appears to have lifecycle hooks (e.g. libp2p.on('peer:connect'), I would not be surprised if this was the case. You may end up tearing your hair out trying to get perfect reactivity on a node that's quietly mutating itself in the background.
If this is the case, and libp2p provides no libp2p.on('update') hook through which you could inform Vuex of changes, then you might want to implement a sort of basic game state loop and simply tell Vue to recalculate everything every so often after a brief sleep. See https://stackoverflow.com/a/40586872/752916 and https://stackoverflow.com/a/39914235/752916. This is a bit of hack (an informed one, at least), but it might make your life a lot easier in the short run until you sort out this thorny bug, and there should be no flicker.
Just a thought, I don't know anything about libp2p but have you try to declare your variable in the data options that change on the update:
data: {
updated: ''
}
and then assigning it a value :
syncNode(state, libp2p) {
this.updated = state
state.p2pNode = libp2p
}

Categories

Resources