Justin S.
Justin S.

Reputation: 43

Vue3 WatchEffect changes prop indirectly?

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

Answers (2)

Tony
Tony

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

Alexander Nenashev
Alexander Nenashev

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>
  • you update the property modelValue.value.property directly - it's ok (like you wondered). Also it goes off the watchEffects radar.
  • you update through update:modelValue - works ok, but in my example watchEffect is invoked (which is interesting, seems modelValue is became a dependency for this watchEffect.
  • you update modelValue.value directly - Vue issues:
[Vue warn] Set operation on key "modelValue" failed: target is readonly.[object Object]

THE GIST IS HERE

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>

THE GIST IS HERE

If you want the readonly state preserved, provide a readonly value:

function updateModel(e){
    value.value = readonly({property: e.target.textContent});
} 

Upvotes: 0

Related Questions