Home > Back-end >  Vue 2 Updating Properties on Object in Array from Nested Component
Vue 2 Updating Properties on Object in Array from Nested Component

Time:11-26

I have been working on a Vue 2 project for a while, and upon upgrading our linting requirements I discovered that we had prop mutation errors in many of our child components. In our project, we pass a singleton object as a prop to many components and were originally updating the object directly from the child components. Vue seems to suggest using the v-bind.sync feature for updating props from child components (or using the equivalent v-bind and v-on). This, however, doesn't solve the issue of prop modification from nested components in an array.

Take this (pseudo)code for example that uses prop mutation:

Note: Assume const sharedObject: { arrayElements: Array<{ isSelected: boolean }> } = ...

Page.vue

<template>
  ...
  <Component1 :input1="sharedObject" />
  ...
</template>

Component1.vue

<template>
  ...
  <template v-for="elem in sharedObject.arrayElements">
    <Component2 :input2="elem" />
  </template>
  ...
</template>

Component2.vue

<template>
  ...
  <q-btn @click="input2.isSelected = !input2.isSelected"></q-btn>
  ...
</template>

What is the proper way of updating a property like input2.isSelected from nested components in Vue 2? All of the approaches I've thought of are flawed.

Flawed Approaches

I believe that we would like to bubble up that input2.isSelected has been modified in Component2 to Page.vue, however, this seems to either lead to messy code or an uneasy feeling that we are just suppressing linting errors in a roundabout way.


To demonstrate the "messy code" approach, first note that Page.vue does not know the index of of elem in sharedObject.arrayElements. Therefore, we would need to emit an object to Page.vue from Component1 which contains the state of input2.isSelected as well of the index of elem in sharedObject.arrayElements. This gets messy quickly. What about the example where we have:

Component1.vue

<template>
  ...
  <template v-for="elem in sharedObject.arrayElements">
    <template v-for="elem2 in elem.arrayElements">
       <Component2 :input2="elem2" />
    </template>
  </template>
  ...
</template>

in this case, then we could need to pass up 2 indices! It doesn't seem like a sustainable solution to me.


The alternative that I thought of is a callback function (passed as a prop through the component hierarchy) that takes as input the element we want to update and an object that contains the properties we want to update (using Object.assign).

This makes me very uneasy since I don't know the real reason why we can't update a pass-by-reference prop from a child component. To me, it seems like it's just a roundabout way of updating the passed-in from Component2 without the linter noticing. If there is some magic modification that happens to props when they're passed to child components, then surely passing in the object that I received in Component2 to the callback function and modifying it in the parent component would basically just be updating the prop in the child component, but more complicated.

What is the proper way of approaching this problem in Vue 2?

CodePudding user response:

Very good question and analysis of the current state of this long-standing issue in Vue ecosystem.

Yes, modifying "value type" props from the child is a problem as it creates runtime issues (parent overwriting the changes when re-rendered) and thus Vue generates a runtime error when this happens...

Modifying a property of object passed as prop is OK from the "code works fine" POV. Unfortunately there are some influential people in the community with opinion (and many who blindly follow them) that this is an anti-pattern. I do not agree with that and raised my arguments many times (for example here). You described the reasons very well - it just creates unnecessary complexity/boilerplate code...

So what you are dealing with is really just a linting rule (vue/no-mutating-props). There is an ongoing issue/discussion that proposes the configuration option that should allow to ease the strictness of the rule with many good arguments but it gets very little attention from the maintainers (feel free to raise your voice there too)

For now what you can do is:

  1. Disable the rule (far from perfect but luckily thanks to a Vue runtime error you can catch the real incorrect cases during development just fine)
  2. Accept the reality and use workarounds

Workaround - use global state (store like Vuex or Pinia)

Note: Pinia is preferred as next version of Vuex will have same API

General idea is to place the sharedObject in the store and use props only to navigate the child components to the right object - in your case the Component2 will receive an index by prop and retrieve the right element from the store using it.

Stores are great for sharing the global state but using is just to overcome the linting rule is bad. Also as a result, components are coupled to the store hence both reusability suffers and testing is harder

Workaround - events

Yes it is possible to create mess and lot of boilerplate code using only events (especially if you nest components more than 2 levels) but there are ways to make things cleaner.

For example in your case Component2 does not need to know the index as you can handle the event like this

// Component1
<template>
  ...
   <template v-for="elem in sharedObject.arrayElements">
    <template v-for="(elem2, index) in elem.arrayElements">
       <Component2 :input2="elem2" @update="updateElement($event, index)" />
    </template>
  </template>
  ...
</template>

In your case, the Component2 handles only change of single boolean property so $event can be simple boolean. If there are more than one property to be changed inside Component2, $event can be an object and you can use object spread syntax to "simplify" the Component2 (using one event instead of multiple - one for each property)

// Component2
<template>
  ...
  <input v-model="someText" type="text">
  <q-btn @click="updateInput('isSelected', !input2.isSelected)"></q-btn>
  ...
</template>
<script>
export default {
  props: ['input2'],
  computed: {
    someText: {
      get() { return this.input2.someText },
      set(newVal) { updateInput('someText', newVal) }
    }
  },
  methods: {
    updateInput(propName, newValue) {
      const updated = { ...this.input2 } // make a copy of input2 object
      updated[propName] = newValue  // change selected property

      this.$emit('update', updated) // send updated object to parent
    }
  }
}
</script>

Well...I prefer just to disable the rule and set some clear naming conventions to indicate that the component is responsible for changing it's input...

Note that there are other workarounds like using this.$parent, inject\provide or event bus but those are really bad

  • Related