Reputation: 43
I have a question regarding the Vue 3 reactivity and mutation of props.
The following code takes a Person Object as prop. After making the prop to a Ref I create a computed property out of it. In the template below I have a list of Tasks to assign to the given Person. When I add or remove a Task the Person Object gets updated in the Parent component even though the emit
of the computed property is not being triggered.
I guess this happens because of the logic contained in the watchEffect
function. But if this is the case I do update the prop which is something I should not do right? Vue however does not give a warning the the prop is being updated.
Can someone tell me if this is a valid way of doing this or is there a better way?
Thank you in advance!
<script setup lang="ts">
interface PersonTaskLinkProps {
modelValue: Person;
}
const props = withDefaults(defineProps<PersonTaskLinkProps>(), {});
const { modelValue } = toRefs(props);
const taskStore = useTaskStore();
const personModel = computed({
get: () => {
return modelValue.value;
},
set: (value) => {
emit('update:modelValue', value);
},
});
const selectedTasks = ref<TaskDTO[]>(
personModel.value.taskIds.map(
(taskId) => taskStore.getEntityById(taskId)! || []
)
);
watchEffect(() => {
personModel.value.taskIds = selectedTasks.value.map((task) => task.id);
});
const allTasks: TaskDTO[] = taskStore.allEntities;
const emit = defineEmits<{
(event: 'update:modelValue', value: Person): void;
}>();
</script>
<template>
<h5>Link Tasks</h5>
<MultiSelect
:title="$t('person.linkTasks')"
v-model:selected-items="selectedTasks"
:selectable-items="allTasks"
options-label="title"
options-value="id"
/>
</template>
Upvotes: 2
Views: 735
Reputation: 1254
I think the issue here lies in the presence of a setter in your computed property:
set: (value) => {
emit('update:modelValue', value);
},
According to the doc, whenever you assign a value to personModel.value
, it triggers the setter, which in turn emits an update event to the parent component, causing the value to be updated.
Now, let's examine where personModel.value
is being called. It appears to be within the watchEffect
block:
watchEffect(() => {
personModel.value.taskIds = selectedTasks.value.map((task) => task.id);
});
This watchEffect
is triggered whenever selectedTasks.value
changes. So, where does selectedTasks.value
change? It seems to be in this code:
v-model:selected-items="selectedTasks"
When you use v-model
(docs), it enables two-way binding. In your MultiSelect
component, it seems that you emit an event, which automatically updates the selectedTasks
in this component.
In my opinion, you don't need the personModel
computed property here. Instead, you can directly use the model.value
prop for selectedTasks
and remove the watchEffect
. Here's an example:
const { modelValue } = toRefs(props);
const taskStore = useTaskStore();
const selectedTasks = ref<TaskDTO[]>(
modelValue.value.taskIds.map(
(taskId) => taskStore.getEntityById(taskId)! || []
)
);
Upvotes: 0
Reputation: 22704
The solution is to use readonly
(described in the end).
In my example I mutate with button click but there's no difference where the model property is mutated (could be in watchEffect
).
The first mutation happens in a watchEffect
by the way.
Vue sets the properties' object (not a property itself!) as read-only like Object.freeze()
does. It's shallow, while you cannot override a property you can change any object properties inside it if it's an object.
From MDN on Object.freeze()
:
Note that values that are objects can still be modified, unless they are also frozen.
I've prepared a gist:
<script setup>
import {toRefs, computed, watchEffect} from 'vue';
const props = defineProps(['modelValue']);
const emit = defineEmits(['update:modelValue']);
const {modelValue} = toRefs(props);
const value = computed({
get(){
return modelValue.value;
},
set(value){
console.log(value)
emit('update:modelValue', value);
}
});
watchEffect(() => {
console.log('watch effect here');
modelValue.value.property = 'watchEffect';
});
function updateModelProperty(e){
modelValue.value.property = e.target.textContent;
}
function updateModel(e){
value.value = {property: e.target.textContent};
}
function updateModelDirectly(e){
modelValue.value = {property: e.target.textContent};
}
</script>
<template>
<div>
{{ JSON.stringify(value, null, 4) }}
</div>
<button type="button" @click="updateModelProperty">Update model property</button>
<button type="button" @click="updateModel">Update model through emit</button>
<button type="button" @click="updateModelDirectly">Update model directly</button>
</template>
<style>
button{
display: block;
margin: 10px 0;
}
</style>
modelValue.value.property
directly - it's ok (like you wondered). Also it goes off the watchEffect
s radar.update:modelValue
- works ok, but in my example watchEffect
is invoked (which is interesting, seems modelValue
is became a dependency for this watchEffect
.modelValue.value
directly - Vue issues:[Vue warn] Set operation on key "modelValue" failed: target is readonly.[object Object]
So the solution is to use readonly
. That way you cannot overwrite the properties but still can overwrite the property as a whole through the emit. If you provide not readonly
value you will be able overwrite properties further:
<script setup>
import {ref, readonly} from 'vue';
import Child from './Child.vue';
const person = ref(readonly({}));
</script>
<template>
<child v-model="person"></child>
</template>
If you want the readonly state preserved, provide a readonly value:
function updateModel(e){
value.value = readonly({property: e.target.textContent});
}
Upvotes: 0