VUE v-model, two-way data binding and editing in multi nested components, dynamic components

rolandcsibrei

Roland Csibrei

Posted on January 5, 2020

VUE v-model, two-way data binding and editing in multi nested components, dynamic components

Hello guys!

Check out my first public VUE sandbox. Check it on desktop. It shows how to deal with multi nested components in VUE and how to pass data with v-model from the topmost component down to the children components.

I am quite new to VUE, so I really just want to share my experiences and not to tell you, how do you must do it. Maybe some more experienced VUEers will highlight the weak spots in the comment section. They are welcome!

So let's move on! At first, this is not a complete beginner article, you need to be familiar with the basics of VUE. The sandbox shows, how to pass data between components just using v-model and without emit, or get, set. There is a value-peeker object, which real time shows the current value for the given component. On the right you can see the root object, App.inputList.

Here is the sandbox:

Some intro to this sandbox

Every article I read about VUE's two-way binding in nested components was showing examples only with two level deep nesting and used one of these techniques to ensure two-way binding:

The first one is creating computed getters and setters for the property:

<template>
  // use the value
</template>
props: {
  value: Object
},
computed: {
  value: {
    get () {
      return this.value
    },
    set (val) {
      this.$emit('input', val)
    }
  }
}

or this, the second technique relies on a watcher:

<template>
  // use the localValue
</template>
props: {
  value: Object
},
data () {
  localValue: {...this.value}
},
watch: {
  localValue(val) {
    this.$emit('input', val)
  }
}

IMHO, nobody ever mentioned, that in multi nested components we do not have to use any of those techniques to pass the object. The only thing we need is v-model.

App.vue

I created the root or base object inputList, which will be edited by multiple nested components:

  data() {
    return {
       inputList: [
        {
          name: "titles",
          label: "Titles",
          type: "my-input-list",
          items: [
            { label: "PhDr", value: "phdr" },
            { label: "Dr", value: "dr" }
          ]
        },

        {
          name: "firstname",
          label: "First name",
          type: "my-input-text"
        },
...

The root object is passed via v-model to a custom component called my-form:

<template>
  <div id="app">
    <div class="col1">
      <my-form v-model="inputList"/>
    </div>
...

Components/Form.vue

Iterates the object value, which is a vue property and was passed by App.vue using the v-model directive. For each entry it creates a custom component. The component is dynamically created using the vue component tag. You have to set it's property called is to the name of the component, you want to create.

<template>
  <div>
    <div v-for="(item, idx) in value" :key="idx">
      <component :is="item.type" v-model="value[idx]"></component>
      <div class="hr"></div>
    </div>

    <!-- Save button -->
    <input type="button" value="SAVE" @click="save" class="save">
  </div>
</template>

You need to register (or load dynamically, it is another story) your components, before you can create them with the component tag.

Javascript imports

import InputText from "./dynamic/InputText";
import InputBoolean from "./dynamic/InputBoolean";
import InputList from "./dynamic/InputList";

...

Registering VUE components:

export default {
  name: "Form",

...

  components: {
    "my-input-text": InputText,
    "my-input-boolean": InputBoolean,
    "my-input-list": InputList
  }
};

Component with dynamic v-model

Maybe you are surprised why I use v-model="value[idx]"

<div v-for="(item, idx) in value" :key="idx">
  <component :is="item.type" v-model="value[idx]"></component>
  <div class="hr"></div>
</div>

instead of v-model="item"

<div v-for="(item, idx) in value" :key="idx">
  <component :is="item.type" v-model="item"></component>
  <div class="hr"></div>
</div>

You can't use the iteration variable item as v-model because of variable scope, so just stick to the first valid solution.

components/dynamic/InputList.vue

<template>
  <div>
    <h1>{{ value.label }}</h1>

    <my-value-peeker v-model="getPeekValue" :label="value.type + '.value:'"/>

    <div class="component">
      <select v-model="value.value" multiple>
        <option v-for="(item, idx) in value.items" :key="idx" :value="item.value">{{ item.label }}</option>
      </select>

      <p>
        <input type="button" value="CREATE NEW ITEM" @click="createNewItem" v-if="!isCreateNewItem">

        <my-input-list-item-editor
          v-model="newItem"
          v-if="isCreateNewItem"
          @confirm="confirmAdd"
          @cancel="cancelAdd"
        />
      </p>
    </div>
  </div>
</template>

Here we have our peeker component with a dynamic :label. Sometimes it makes sence to do it this way, however it would be cleaner to have a computed label property in <my-value-peeker> which alters the label.

<template>
  <div>
    <h1>{{ value.label }}</h1>

    <my-value-peeker v-model="getPeekValue" :label="value.type + '.value:'"/>   
...

Set the v-model attribute for the html select tag

      <select v-model="value.value" multiple>
        <option 
          v-for="(item, idx) in value.items" 
          :key="idx" 
          :value="item.value"
         >{{ item.label }}</option>
      </select>
...

We have a my-input-list-item-editor, which allows us to add new records to the collection contained in items. This component emits two custom events called confirm and cancel. The names are self explaining.

        <my-input-list-item-editor
          v-model="newItem"
          v-if="isAdd"
          @confirm="confirmAdd"
          @cancel="cancelAdd"
        />
 confirmAdd() {
      // hide add UI
      this.isAdd = false;

      // push a shallow copy
      this.value.items.push({ ...this.newItem });
    },

The method confirmAdd just adds a shallow copy of newItem to the collection of items on the components value object. Since it is an Array, we can use push. Create a shallow copy (deep clone is not needed) of the object prior to adding it to the collection. If your newItem contains objects references, you would end up with creating a deep clone of your object instead of the shallow copy.

Use

const arrayDeepClone = JSON.parse(JSON.stringify(originalArray));

or

const arrayDeepClone  = Vue._.cloneDeep(originalArray);

For the second one you need to install vue-lodash and set it up. I just ended up adding the initialization directly to main.js. If you are using a framework which hides main.js from you, you have to initialize it other way. For example in Quasar you end up with adding a new boot component.

import VueLodash from "vue-lodash";
Vue.use(VueLodash);

And the event 'cancel' will be handled by method cancelAdd. It just hides the add new item UI.


    cancelAdd() {
      // hide add UI
      this.isAdd = false;
    }

components/dynamic/InputText.vue

This is pretty basic, just pass the v-model to the input.

<template>
  <div>
    <my-value-peeker v-model="getPeekValue" :label="value.type"/>
    <div class="component">
      <label>{{ value.label }}:</label>
      <input type="text" v-model="value.value">
    </div>
  </div>
</template>

Just the basic stuff.

It is always a good idea to use this style of property definition and set the property type, if it is required, and if not, you should return a default value.

export default {
  props: {
    // v-model injects the object to be edited into props.value
    value: {
      type: Object,
      required: true
    },    
  },
  methods: {
    // just the value for the peeker
    getPeekValue() {
      return this.value;
    }
  }
};

As you can see no emit here, no local copies, no setters, no getters, just the v-model directive. We are actually ended up editing a string property called value on object value. This way we are editing directly the objects properties, but we do not touch the object reference itself.

components/dynamic/InputBoolean.vue

The same basic thing, as in InputText, just the input was changed to a checkbox.

<template>
  <div>
    <my-value-peeker v-model="getPeekValue" label="InputBoolean"/>
    <div class="component">
      <label>{{ value.label }}</label>
      <input v-model="value.value" type="checkbox" label="YES">
    </div>
  </div>
</template>

<script>
export default {
  props: {
    value: {
      type: Object,
      required: true
    }
  },
  methods: {
    getPeekValue() {
      return this.value;
    }
  }
};
</script>

components/InputListItemEditor.vue

We have one my-value-peeker object here and two html input tags, bound to value.label and value.value using v-model.

<template>
  <div>
    <my-value-peeker v-model="getPeekValue" label="InputText"/>
    <div class="component">
      <p>
        <label>Label:</label>
        <input type="text" v-model="value.label">
      </p>
      <p>
        <label>Name:</label>
        <input type="text" v-model="value.value">
      </p>
      <input type="button" value="Confirm" @click="confirm">
      <input type="button" value="Cancel" @click="cancel">
    </div>
  </div>
</template>

As far as we want to have control over when the value, which gets written to the parent component, we use two buttons. Both of them has click event listeners set.

confirm() {
  this.$emit("confirm", this.value);
},

This sends or emits a message to the parent component, in our case to components/dynamic/InputList.vue. Our parent component handles these messages as written above. In confirm method we send a reference to the object to the parent component.

You can send more parameters with $emit. If two or more parameters are used, send the payload as object, like this: this.$emit("confirm", { value: this.value, entity: "users", counter: 0, immediate: false})

This way you are building your object by setting property names in v-model. Beware of typos, you can easily end up with objects with mispelled property names.

Bonus

For demonstration purposes I added a button to App.vue which modifies the inputList[0] and adds a new item to it. You can see how the change is immediatelly propagated to all children components.

    addTitle() {
      this.inputList[0].items.push({
        label: "Kokki-" + new Date().getTime(),
        value: "kokki-" + new Date().getTime()
      });
    }

And one more: If you press the CREATE NEW ITEM in a ListItem component a counters starts to count presses. It is just for demonstration, how can we modify the base object from the child component.

Conclusion

v-model is cool!

Hopefully someone will find this article helpfull! Let me know in the comments!

Feel free to play on my sandbox, comments/critics are appreciated.

Keep coding!

💖 💪 🙅 🚩
rolandcsibrei
Roland Csibrei

Posted on January 5, 2020

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related