DEV Community

nop33.eth
nop33.eth

Posted on • Originally published at iliascreates.com on

Fixing no-mutating-props Vue Error with v-model

During last summer I was working on a Vue (and Vuex) side-project. I hadn’t touched it for almost 2 months due to lack of time and now I finally found the time to work on it again. Naturally, the first thing I do in these cases is to check that all my packages are up to date. Of course, shit loads of things changed these past 2 months in Frontendland :P

yarn upgrade

After upgrading all my packages I noticed that eslint is complaining about something. Apparently, I was violating the vue/no-mutating-props rule (since I am using the plugin:vue/essential eslint plugin) because I am changing the value of a prop passed from a parent component to a child component, within the child component. The official docs declare this as an anti-pattern with the following explanation:

Due to the new rendering mechanism, whenever the parent component re-renders, the child component’s local changes will be overwritten.

Seems like I haven’t thought my component design very thoroughly… Oopsie! Time to fix it.

To give a bit of context, the screen where the error appeared included 2 components:

  • /src/views/ItemAdd.vue (red box)
  • /src/components/ItemForm.vue (green box)

App screenshot

and the (simplified) code looked like this:

<!-- /src/views/ItemAdd.vue -->
<template>
  <div>
    <v-toolbar>
      <!-- ... -->
    </v-toolbar>
    <v-main>
      <ItemForm :item="item" />
    </v-main>
  </div>
</template>

<script>
  import ItemForm from "@/components/ItemForm.vue"

  export default {
    components: {
      ItemForm,
    },
    props: ["flatId"],
    data: () => {
      return {
        flat: {},
        item: {
          name: "",
          // ...
        },
      }
    },
    created() {
      this.flat = this.getFlat(this.flatId)
      this.item.name = this.flat.name
      // ...
    },
    methods: {
      // ...
    },
  }
</script>

<!-- /src/components/ItemForm.vue -->
<template>
  <v-form>
    <v-text-field v-model="item.name" label="Item name" />
    <!-- ... -->
  </v-form>
</template>

<script>
  export default {
    props: ["item"],
  }
</script>
Enter fullscreen mode Exit fullscreen mode

This was working perfectly fine :P The v-model was modifying the property passed to the child component and I didn’t have to pass it back to the parent component so that it can be saved when clicking the Save button of the parent component’s toolbar. But that’s a bad pattern.

How NOT to do it

Since I’ve been using Vuex, my initial thought to tackle this problem was to use (what else) the central store.

I started by updating the store, adding the new item property and creating the necessary mutations and actions:

// /src/store/index.js
export default new Vuex.Store({
  state: {
    item: {
      name: '',
      // ...
    },
    // ...
  },
  mutations: {
    SET_ITEM_NAME (state, name) {
      state.name = name
    },
    // ...
  },
  actions: {
    setItemName ({ commit }, name) {
      commit('SET_ITEM_NAME', name)
    },
    // ...
  }
}
Enter fullscreen mode Exit fullscreen mode

continued by updating the FormAdd.vue view component, removing the unnecessary prop and updating the value in the store:

- <ItemForm :item="item" />
+ <ItemForm />

...

   created () {
     this.flat = this.getFlat(this.flatId)
-    this.item.name = this.flat.name
+    this.$store.dispatch('setItemName', this.flat.name)
   },
Enter fullscreen mode Exit fullscreen mode

Finally, updated the ItemForm.vue component to remove the prop and update the v-model to use the value from the store instead:

- <v-text-field v-model="item.name" label="Item name" />
+ <v-text-field v-model="name" label="Item name" />

...

 export default {
   props: [
-    'item',
     'allFlatmates'
   ],
+  computed: {
+    name: {
+      get () {
+        return this.$store.state.item.name
+      },
+      set (value) {
+        this.$store.dispatch('setItemName', value)
+      }
+    },
+ }
Enter fullscreen mode Exit fullscreen mode

This solution seems to work initially. I created an item and it worked. Only to realize that the next time I tried to create an item, the form was already initialized with the data I inserted in the previous item. Dah! The store needs to be reset after every item creation. Which means I needed to:

  • generate the default state of the item and return it through a function
  • iterate through every property of the item property stored in the store and deep copy the default values
  • create more actions and mutations
  • and I haven’t even gone into the FormEdit.vue view component yet.

Meh…

Using the central store doesn’t look like the right way for this use-case.

How to do it with v-model

Taking a step back and considering what options there are for parent-child component communication in Vue, the most obvious that comes to mind is passing the data from the parent to the child using props and the other way around with emitting events.

But there’s a way that combines both of those and that’s custom inputs using v-model.

<ItemForm v-model="item" />
Enter fullscreen mode Exit fullscreen mode

is the same as

<ItemForm v-bind:value="item" v-on:input="item = $event" />
Enter fullscreen mode Exit fullscreen mode

So all we need to do is replace the prop with a v-model in the parent component and make sure we emit an input event every time one of the fields of the form changes.

Going back to the ItemAdd.vue view component, I updated it replacing the prop with a v-model:

- <ItemForm :item="item" />
+ <ItemForm v-model="item" />
Enter fullscreen mode Exit fullscreen mode

Finally, in the ItemForm.vue component we need to clone the value property into the local data property called item, update it whenever there’s an input event in the form fields and emit it back to the parent:

- <v-text-field v-model="item.name" label="Item name" />
+ <v-text-field :value="value.name" @input="nameChanged($event)" label="Item name" />

...

 <script>
 export default {
   props: [
-    'item',
+    'value',
     'allFlatmates'
   ],
   data () {
     return {
+      item: {},
     }
   },
+  watch: {
+    value (newValue) {
+      if (Object.keys(this.item).length === 0 && this.item.constructor === Object) {
+       this.item = newValue
+      }
+    },
+  },
+  created () {
+    this.item = { ...this.value }
+  },
+  methods: {
+    nameChanged ($event) {
+      this.item.name = $event
+      this.$emit('input', this.item)
+    },
+  }
Enter fullscreen mode Exit fullscreen mode

In case you are wondering about the watcher, I had to create it because the value prop might not yet have a value when the component is created since it has to come asynchronously from an API.

And that’s it! So many fewer lines of code, so many fewer things to worry about.

Conclusion

Even if you are using Vuex for state management in a project, it should not be considered the only way of communicating data amongst components. This use-case demonstrates the simplicity of using Vue’s default features.

I hope this post helps somebody!

Top comments (0)