Tu propio v-model en un componente Vue (The right way)

Tu propio v-model en un componente Vue (The right way)

Si has usado componentes de Vue (o incluso un input básico) habrás usado v-model para enlazar un valor en el componente padre y en el componente hijo.

<input v-model="message">
// o
<datepicker v-model="date"></datepicker>

Si en el componente padre (aquel donde incluimos datepicker) modificamos el valor de date automáticamente se modificará el valor dentro del componente, de igual forma si el componente modifica el valor de date el valor se modificará en el padre. Esto lo que permite, por ejemplo, es que cuando en un campo input escribimos algo en el componente padre se pueda mostrar lo que escribimos usando {% raw %}{{ date }}{% endraw %}

Pues bien, realmente v-model es la combinación de la prop value y el evento input, es decir para que nuestro componente disponga de su v-model debe tener un aspecto similar a este

<template>
  <label>Fecha: </label>
  <input v-model="value" @change="$emit('input', value)">
</template>

<script>
export default {
  props: {
    value: {
      required: true
    }
  }
}
</script>

¿Y ya está?

Pues no, no está. Si recuerdas, las propiedades de un componente deben ser inmutables (One-way data flow) desde el propio componente, y si te fijas metiéndolo dentro del v-model del input cada vez que se escriba algo en el, el valor de la propiedad cambiará, y esto (aunque funciona) genera un error como el siguiente en la consola:

Error message: Avoid mutating a prop directly since the value will be overwritten whenever the parent component re-renders. Instead, use a data or computed property based on the prop’s value.

Para resolver esto nuestro componente debe usar una variable interna intermedia, que tome el valor de la propiedad de inicio y que cuando cambie emita el evento, también debemos tener en cuanta que cuando el valor de la propiedad cambie la variable local tome el nuevo valor.

La forma más simple de hacerlo es con una variable interna, a la que llamaremos localValue y dos watchers, uno para emitir el evento input con el valor de localValue y otro para modificar el valor de localValue si el valor de value cambia desde el exterior.

Y el componente nos quedará así:

<template>
  <label>Fecha: </label>
  <input v-model="localValue">
</template>

<script>
export default {
  props: {
    value: {
      required: true
    }
  },
  data () {
    return {
      localValue: this.value
    }
  },
  watch: {
     localValue (newValue) {
       this.$emit('input', newValue)
     },
     value (newValue) {
       this.localValue = value
     }
  }
}
</script>

Como ves eliminamos el @change para que el cambio en el <input> pase a ser controlado en uno de los watchers.

A primera vista podría parecer que esto provocarían un bucle infinito, ya que al modificar localValue emitimos el evento que modifica el valor de value en el componente padre y este a su vez modifica de nuevo localValue repitiéndose otra vez, pero los watchers solo se ejecutan cuando el valor cambia, es decir como el valor de localValue y value coincide una vez emitido el evento input el segundo watcher no se dispara deteniendo el bucle.

Podemos hacerlo usando propiedades calculadas (computed), empleando los getters y setters de estas:

...
computed: {
  computedValue: {
    set(value) {
      this.localValue = value
    },
    get() {
      return this.localValue
    }
  }
}
...

pero seguimos necesitando la variable localValue y el watcher de value, con lo cual para mi gusto, a parte de no ahorrar código, complicamos la legibilidad.

De sencilla esta forma cumplimos con la regla de One-Way Data Flow sin complicar demasiado el código.

Como último apunte podríamos llevar esto a un mixin:

// custom.vmodel.mixin.js
export default {
  props: {
    value: {}
  },
  data () {
    return {
      localValue: this.value
    }
  },  
  watch: {
     localValue (value) {
           this.$emit('input', value)
         },
    value (value) {
      this.localValue = value
    }
  }
}

y solo lo tendríamos que importar en nuestro componente que quedaría así:

<template>
  <label>Fecha: </label>
  <input v-model="localValue">
</template>

<script>
import customvmodelMixin from './custom.vmodel.mixin.js'
export default {
  mixins: [customvmodelMixin]
  props: {
    //Otras propiedades
  },
  data () {
    return {
      // Más variables
    }
  },  
}
</script>