Search code examples
javascriptformsvue.jsvuetify.jsvuelidate

wrapped v-text-field causes warnings and doesn't refresh data


I'm new to vue.js and I'm trying to make a form, using Vue, vuetify, and vuelidate. If I don't wrap the v-text-field : no problem. If I wrap it in components : problem. The value is not updated, and there is a warning "[Vue warn] Set operation on key "model" failed: target is readonly. "

You can see what's happening on this sandbox :

https://codesandbox.io/s/distracted-newton-dx9p4s?file=/src/components/Form.vue

And see the code (that I'm focusing on) here :

Form.vue :

<script setup>
import { reactive } from "vue";
import { useVuelidate } from "@vuelidate/core";
import { required } from "@vuelidate/validators";
import StyledInput from "./StyledInput.vue";
import StyledInput2 from "./StyledInput2.vue";
import StyledInput3 from "./StyledInput3.vue";

const formData = reactive({
  username: "",
  username2: "",
  username3: "",
  username4: "",
});

const rules = {
  username: { required },
  username2: { required },
  username3: { required },
  username4: { required },
};

const v$ = useVuelidate(rules, formData);
</script>

<template>
  <v-text-field
    v-model="formData.username"
    label="Username 1"
    @input="v$.username.$touch;"
    @blur="v$.username.$touch"
    variant="outlined"
  ></v-text-field>
  <div>{{ formData.username }}</div>
  <StyledInput
    :model="formData.username2"
    label="Username 2"
    :fieldData="v$.username2"
  />
  <div>{{ formData.username2 }}</div>
  <StyledInput2
    :model="formData.username3"
    label="Username 3"
    :fieldData="v$.username3"
  />
  <div>{{ formData.username3 }}</div>
  <StyledInput3
    v-model="formData.username4"
    label="Username 4"
    :fieldData="v$.username4"
  />
  <div>{{ formData.username4 }}</div>
</template>

<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped>
</style>

MyInput.vue :

<script setup>
const props = defineProps({
  model: {
    //type: String,
    required: true,
  },
  label: {
    type: String,
    required: true,
  },
  fieldData: {
    type: Object,
    required: true,
  },
});
</script>

<template>
  <v-text-field
    v-model="props.model"
    :label="props.label"
    :error-messages="props.fieldData.$errors.map((e) => e.$message)"
    @input="props.fieldData.$touch;"
    @blur="props.fieldData.$touch"
    variant="outlined"
  ></v-text-field>
</template>

<style></style>

StyledInput.vue:

<script setup>
import MyInput from "./MyInput.vue";

const props = defineProps({
  model: {
    //type: String,
    required: true,
  },
  label: {
    type: String,
    required: true,
  },
  fieldData: {
    type: Object,
    required: true,
  },
});
</script>

<template>
  <MyInput v-bind="props"></MyInput>
</template>

<style></style>

I provided more different attempts on the sandbox. I don't understand what I'm doing wrong, any idea?

Thank you


Solution

  • [Vue warn] Set operation on key "model" failed: target is readonly.

    This warning comes from mutating a prop, which is happening here on the v-text-field:

    <v-text-field
      v-model="props.model"
      ...
    ></v-text-field>
    

    Props should be treated as readonly because they represent one-way data flow. To get around this limitation, desired mutations can be emitted to the parent which can safely perform the mutation on the original object. This can pair nicely with using v-model because the custom child component receiving a v-model receives it as a prop and emits the changes to the parent.

    The given codesandbox is not the most simple example to work with because the prop goes two child components deep, but it can be done. I would set v-model on StyledInput:

    Form.vue

    <StyledInput
        v-model="formData.username2"
        label="Username 2"
        :fieldData="v$.username2"
      />
    

    In the first child component, define the v-model prop modelValue, then continue passing props to the next child and set up the emit:

    StyledInput.vue

    const props = defineProps({
      modelValue: {
        //type: String,
        required: true,
      },
    })
    
    <MyInput
      v-bind="props"
      @update:modelValue="$emit('update:modelValue', $event)"
    ></MyInput>
    

    Then basically repeat the same thing in the next child component, but now also setting the value prop of the v-text-field and emit changes based on the input event:

    MyInput.vue

    const props = defineProps({
      modelValue: {
        //type: String,
        required: true,
      },
    
    <v-text-field
      :value="props.modelValue"
      @input="$emit('update:modelValue', $event.target.value)"
    ></v-text-field>
    

    :value="props.modelValue" is a one-way binding. v-text-field will display the value of props.modelValue but not overwrite it. When a user inputs a new value, the event with new value is emitted up all the way to Form.vue where the actual mutation is made (the v-model). Once the new value is set, it flows back down to be redisplayed in v-text-field's :value. It all happens so fast as to appear seamless.

    updated codesandbox

    Finally, just as a tip, if passing data between multiple levels of components feel too messy or too difficult to maintain, consider using a global store like Pinia