Search code examples
javascriptvue.jsvuejs2vue-componentvue-props

How to prevent updated data in Child component from updating Parent's data in VueJS [2.6.14]


I am using vuejs 2.6.14 and am running into the following issue :

Modified data from child component updates data in parent component aswell, without any use of $emit in code.

This is the opposite of the usual "how to update data in child from parent / how to update in parent from child"

Here is my code in greater details:

I have a parent component named Testing.vue, passing a JSON object ("userData") to a child, GeneralData.vue.

This is what the code looks like for the parent :

<template>
  <div id="testing-compo">
    <div style="margin-top: 1rem; margin-bottom: 1rem; max-width: 15rem">
          <label
            class="sr-only"
            for="inline-form-input-username"
            style="margin-top: 1rem; margin-bottom: 1rem"
            >Account settings for :</label
          >
          <b-form-input
            v-model="username"
            id="inline-form-input-username"
            placeholder="Username"
            :state="usernameIsValid"
          ></b-form-input>
        </div>
    <b-button class="button" variant="outline-primary" 
    @click="callFakeUser">
    Populate fake user
    </b-button>
    <GeneralData :userData="user" />
  </div>
</template>
<script>
export default {
  name: "Testing",
  components: {
    GeneralData,
  },
  data() {
    return {
      user: null,
      username: null,
    };
  },
  computed: {
    usernameIsValid: function () {
      if (this.username != null && this.username.length >= 4) {
        return true;
      } else if (this.username != null) {
        return false;
      }

      return null;
    },
  },
  methods: {
    async callFakeUser() {
      userServices.getFakeUser().then((res) => {
        this.user = res;
        console.log(this.user);
      });
    },
</script>

A very simple testing component that calls userServices.getFakeUser(), which asynchronously returns a JSON object.

For the child :

<template>
  <div id="general-compo">
    <!-- AGE -->
    <div class="mt-2">
      <label for="text-age">Age</label>
      <div>
        <b-form-input
          v-model="userAge"
          placeholder="+18 only"
          class="w-25 p-1"
          type="number"
        >
        </b-form-input>
      </div>
    </div>
    <!-- LANGUAGES -->
    <div class="mt-2">
      <label for="lang-list-id">Language(s)</label>
      <div
        v-for="langKey in userLangsCount"
        :key="langKey"
        style="display: flex; flex-direction: row"
      >
        <b-form-input
          readonly
          :placeholder="userLangs[langKey - 1]"
          style="max-width: 50%; margin-top: 0.5rem"
          disabled
        ></b-form-input>

        **This form is set to read only, for display purposes only**

        <b-button
          variant="outline-danger"
          @click="removeLang(langKey)"
          style="margin-top: 0.5rem; margin-left: 1rem"
          >Remove</b-button
        >

        **This button removes a language from the userLangs array by calling removeLang(langKey)**

      </div>
      <div style="display: flex; flex-direction: row">
        <b-form-input
          v-model="userCurrentLang"
          list="langlist-id"
          placeholder="Add Language"
          style="max-width: 50%; margin-top: 0.5rem"
        ></b-form-input>
        <datalist id="langlist-id">
          <option>Manual Option</option>
          <option v-for="lang in langList" :key="lang.name">
            {{ lang.name }}
          </option>
        </datalist>
        <b-button
          :disabled="addLangBtnDisabled"
          variant="outline-primary"
          @click="addLang()"
          style="margin-top: 0.5rem; margin-left: 1rem"
          >Add</b-button
        >
      </div>
    </div>
  </div>
</template>
<script>
import langList from "../assets/langList";
export default {
  name: "GeneralData",
  components: {},
  props: {
    userData: Object,
  },
  data() {
    return {
      userAge: null,
      langList: langList,
      userLangs: [],
      userCurrentLang: null,
    };
  },
  watch: {
    //Updating tabs with fetched values
    userData: function () {
      this.userLangs = this.userData.general.langs;
      this.userAge = this.userData.general.age
    },
  },
  computed: {

    **userGeneral is supposed to represent the data equivalent of userData.general, it is therefore computed from the user input, its value is updated each time this.userAge or this.userLangs changes**

    userGeneral: function () {
      //user data in data() have been filled with userData values
      return {
        age: this.userAge,
        langs: this.userLangs,
      };
    },

**returns the amount of languages spoken by the user to display them in a v-for loop**
    userLangsCount: function () {
      if (this.userLangs) {
        return this.userLangs.length;
      }
      return 0;
    },

**gets a list of languages name from the original JSON list for display purposes**
    langNameList: function () {
      let namelist = [];
      for (let i = 0; i < this.langList.length; i++) {
        namelist.push(langList[i].name);
      }
      return namelist;
    },

**returns true or false depending on whether entered language is in original list**
    addLangBtnDisabled: function () {
      for (let i = 0; i < this.langList.length; i++) {
        if (this.userCurrentLang == langList[i].name) {
          return false;
        }
      }
      return true;
    },
  },
  methods: {
    addLang() {
      this.userLangs.push(this.userCurrentLang);
      this.userCurrentLang = null;
    },
    removeLang(key) {
      this.userLangs.splice(key - 1, 1);
    },
  }
}
</script>

Here is what the data looks in the vuejs dev tool inside the browser after having updated this.user in Testing.vue:

Data in Testing.vue :

user : {
 general:{"age":22,"langs":["French"]}
}

Data in GeneralData.vue :

userData : {
  general:{"age":22,"langs":["French"]}
}

userAge : 22

userLangs : ["French"]

userGeneral : 
{
  general:{"age":22,"langs":["French"]}
}

So far so good right?

Well here is where the issues happen, if I change the age field in my form, userAge gets incremented, userGeneral.age gets update, but userData.general.age doesnt. Which is expected as userGeneral.age is computed out of this.userAge, and userData is a prop so it shouldn't be mutated as a good practice (and not method sets userData.general.age = xxx anyways). HOWEVER, if i hit the Remove button next to French in the language list, this.userLangs gets updated as it should and is now [], this.userGeneral.langs gets updated to [] aswell as it is computed directly from the former. And userData.general.langs ... gets updated to [] aswell which really makes no sense to me.

Worse, in the parent, Testing.vue, user.general.langs is now set to [] aswell.

So somehow, this.userLangs updated the prop this.userData, AND this prop has updated it's original sender user in the parent component, although no $emit of any kind has been involved.

I do not want this to happen as I dont think it's supposed to happen this way and is therefore an hasard, but also because i want to setup a 'save' button later on allowing the user to modify his values all at once.

What i've tried : setting all kinds of .prevent, .stop on the @click element on the Remove / Add buttons, in the method called itself, adding e.preventDefault (modifying addLang and removeLang to send the $event element aswell), none of those attemps have solved anything.

Hopefully I didnt implement the .prevent part correctly, and someone can help me block this reverse-flow annoying issue.


Solution

  • Solution to the problem here is that lang is an array that is passed as a reference thus a mutation is bubbled up to the parent. Instead of assigning the original array we can just assign a copy of that

    userData: function () {       this.userLangs = [...this.userData.general.langs];       this.userAge = this.userData.general.age     }