Passing a form object as a vue prop

Passing a form object as a vue prop
Photo by Kira auf der Heide / Unsplash

Setup

Let's say we have a parent component:

<template>
  <div>
    <p>This is my awesome form</p>
    
    <form>
      <input v-model="form.a">
      <input v-model="form.b">
    </form>
  </div>
</template>

<script>
export default {
  setup() {
    const form = reactive({
      a: "foo",
      b: "bar",
    });
  }
}
</script>

Parent.vue

Now because we want to re-use this exact form on many places in our app we move it to a child component like this:

<template>
  <div>
    <p>This is my awesome form</p>
    
    <my-form :form="form"/>
  </div>
</template>

<script>
import MyForm from "./MyForm.vue"

export default {
  components: {
      MyForm,
  },
  setup() {
    const form = reactive({
      a: "foo",
      b: "bar",
    });

    return {
      form
    }
  }
}
</script>

Parent.vue

<template>
  <form>
    <input v-model="form.a">
    <input v-model="form.b">
  </form>
</template>

<script>
export default {
  props: ["form"],
  setup() {}
}
</script>

MyForm.vue

While this looks easy and does work, it breaks vue's one-way data flow:

When objects and arrays are passed as props, while the child component cannot mutate the prop binding, it will be able to mutate the object or array's nested properties. This is because in JavaScript objects and arrays are passed by reference, and it is unreasonably expensive for Vue to prevent such mutations.
The main drawback of such mutations is that it allows the child component to affect parent state in a way that isn't obvious to the parent component, potentially making it more difficult to reason about the data flow in the future. As a best practice, you should avoid such mutations unless the parent and child are tightly coupled by design. In most cases, the child should emit an event to let the parent perform the mutation.

Solution 1: just use it like this

So if you are fine with not following the best practice and you know what you are doing you can mutate an object prop. But we aware that it can, in some cases, cause reactivity issues.

If you are using eslint-plugin-vue's recommended rules you probably have vue/no-mutating-props screaming at you. There is a config option "shallowOnly" that disables warnings about modifying object props.

Solution 2: local object and watch

First we create our own, local, reactive form object, then we create two watchers. One for keeping our local object up-to-date if the parent form prop changes and one that emits an update event when anything in our local object is modified.

<template>
  <form>
    <input v-model="data.a">
    <input v-model="data.b">
  </form>
</template>

<script>
export default {
  props: ["form"],
  emits: ["update"],
  setup(props, {emit}) {
    //initial import
    const data = reactive(props.form);

    //dataflow down
    watch(props.form, v => Object.assign(data, v))

    //dataflow up
    watch(data, v => emit("update", v))

    return {
      data
    }
  }
}
</script>

MyForm.vue

Modification of the prop is now done in the parent, not the child.

<my-form :form="form" @update="d => Object.assign(form, d)" />

Parent.vue

Object.assign has a neat side-effect, it allows the form object to have methods and be more complex. For me this was the case when using the inertiajs form helper. I only had to modfiy the initial import so that my local object and the data emitted by the update event does not include the methods

...
//initial import
const data = reactive(form.data());
...

Solution 2.1

One more reason for the Object.assign in the previous example is because assigning d to form would replace the reactive object, breaking reactivity (in our case it would not work at all because we defined it as a const). So a variation would be to use a nested property:

...
<my-form :form="data.form" @update="d => data.form = d" />
...

<script>
  ...
  const data = reactive({
    form: {
      a: "foo",
      b: "bar",
    }
  });
  
  return { data }
  ...
</script>

Parent.vue

Solution 2.2

This can be modified further by using v-model

...
<my-form v-model="data.form" />
...

Parent.vue

<template>
  <form>
    <input v-model="data.a">
    <input v-model="data.b">
  </form>
</template>

<script>
export default {
  props: ["modelValue"],
  emits: ["update:modelValue"],
  setup(props, {emit}) {
    //initial import
    const data = reactive(props.modelValue);

    //dataflow down
    watch(props.modelValue, v => Object.assign(data, v))

    //dataflow up
    watch(data, v => emit("update:modelValue", v))

    return {
      data
    }
  }
}
</script>
💡
Note that Object.assign does not handle the deletion of props from the form object, in this case you should also use a nested reactive object in the local version.

Solution 3: lots of models

From what I could find online, vue's intended way is defining a model for each value.

<my-form v-model:a="form.a" v-model:b="form.b"/>

Parent.vue

<template>
  <form>
    <input v-model="a">
    <input v-model="b">
  </form>
</template>

<script setup>
const a = defineModel('a')
const b = defineModel('b')
</script>

MyForm.vue

What I don't like about this is that I have to define each value twice, once in the parent template and once in the child setup. Imagine doing this for a big form. Furthermore it seems like this only works when using the new script setup syntax and doing it in the pre 3.4 syntax you would need a lot of code.