13

I have an issue in the two way binding of a reactive component in vue 3 using the composition API.

The setup:

The parent calling code is:

<template>
  <h1>{{ message.test }}</h1>
  <Message v-model="message" />
</template>

<script>
import Message from '@/components/Message.vue';
import { reactive } from 'vue';

export default {
  name: 'Home',
  components: { Message },

  setup() {
    const message = reactive({ test: '123' });

    return {
      message
    };
  }
};
</script>

The child component code is:

<template>
  <label>
    <input v-model="message" type="text" />
  </label>
</template>

<script>
import { computed } from 'vue';

export default {
  props: {
    messageObj: {
      type: Object,
      default: () => {},
    },
  },

  emits: ['update:messageObj'],

  setup(props, { emit }) {
    const message = computed({
      get: () => props.messageObj.test,
      set: (value) => emit('update:messageObj', value),
    });

    return {
      message,
    };
  },
};
</script>

The problem:

When the component is loaded, the default value from the object is shown in the input field. This is as it should be, however, when I update the value in the input box the H1 in the parent view is not getting updated with the new input box value.

I have searched through the stackoverflow board and google but have not found any hint as to what needs to be done to make the object reactive.

I read through the reactivity documentation but still have not found any solution for my issue.

For testing I have changed message to be a ref and using this single ref value the data remains reactive and everything is working as expected.

Any pointers on what can be the issue with the reactive object not updating?

Peter Pallen
  • 349
  • 1
  • 2
  • 10
  • it's unclear what you're passing to the child. `v-model:test="message"` doesn't make sense in the context of the rest of the code. Are you passing the entire `reactive` or just a variable (`test`) to the child? – Daniel Nov 10 '20 at 21:35
  • Hi Daniel, The v-model:test="message" is indeed not right, it's still code that I forgot to change back after another test. It should read ```js ``` – Peter Pallen Nov 10 '20 at 21:39

5 Answers5

13

Here

<div id="app">
    <h1>{{ message.test }}</h1>
    <child v-model="message"></child>
</div>
const { createApp, reactive, computed } = Vue;


// -------------------------------------------------------------- child
const child = {
    template: `<input v-model="message.test" type="text" />`,
    
    props: {
        modelValue: {
            type: Object,
            default: () => ({}),
        },
    },

    emits: ['update:modelValue'],

    setup(props, { emit }) {
        const message = computed({
            get: () => props.modelValue,
            set: (val) => emit('update:modelValue', val),
        });

        return { message };
    }
};


// ------------------------------------------------------------- parent
createApp({
    components: { child },

    setup() {
        const message = reactive({ test: 'Karamazov' });

        return { message };
    }
}).mount('#app');
Matt
  • 8,758
  • 4
  • 35
  • 64
  • 3
    Hi Matt, This provides a solution but it moves some of the logic that I would like to have in the calling vue to the component. The aim of my setup is that i would end up having logic in the calling vue that is passed onto components that are added to that page so making in fact " dumb" components that only receive data from the parent and emit back updates that are made in the form of the component. I have found the solution after some further testing. I will post my observations in my answer to the question. – Peter Pallen Nov 10 '20 at 22:16
7

Solution and observations:

In the parent view which is calling the component you can use v-model and add a parameter to that v-model if you need to pass only one of the values in the object.

<template>
  <h1>{{ message.test }}</h1>
  <!-- <h1>{{ message }}</h1> -->
  <Message v-model:test="message" />
</template>

<script>
import Message from '@/components/Message.vue';
import { reactive } from 'vue';

export default {
  name: 'Home',
  components: { Message },

  setup() {
    const message = reactive({ test: '123' });

    return {
      message
    };
  }
};
</script>

In the receiving component you then register the parameter of the object that was passed in props as an object.

<template>
  <label>
    <input v-model="message.test" type="text" />
  </label>
</template>

<script>
import { computed } from 'vue';

export default {
  props: {
    test: {
      type: Object,
      default: () => {}
    },
  },

  emits: ['update:test'],

  setup(props, { emit }) {
    const message = computed({
      get: () => props.test,
      set: (value) => emit('update:test', value),
    });

    return {
      message,
    };
  },
};
</script>

If you need to pass the whole object you need to use as a prop in the component the name modelValue.

Change in parent compared to previous code:

<template>
  <h1>{{ message.test }}</h1>
  <!-- <h1>{{ message }}</h1> -->
  <Message v-model="message" />
</template>

Code of the component:

<template>
  <label>
    <input v-model="message.test" type="text" />
  </label>
</template>

<script>
import { computed } from 'vue';

export default {
  props: {
    modelValue: {
      type: Object,
      default: () => {}
    },
  },

  emits: ['update:modelValue'],

  setup(props, { emit }) {
    const message = computed({
      get: () => props.modelValue,
      set: (value) => emit('update:modelValue', value),
    });

    return {
      message,
    };
  },
};
</script>
Peter Pallen
  • 349
  • 1
  • 2
  • 10
5

Your initial problem is quite simple. In Vue 3 v-model defaults to to a prop called modelValue and emits come from update:modelValue. Other answers here have assumed that in their solutions but not directly addressed it.

You can either rename your messageObj prop to use the default prop OR use the multi-model features in Vue 3:

<Message v-model:messageObj="message" />

However our problems run deeper.

All (current) answers will work but aren't quite correct. They all fail the idiomatic "One-way Data Flow" rule.

Consider this JSFiddle, modified from this answer.

const child = {
    template: `<input v-model="message.test" type="text" />`,
    setup(props, { emit }) {
        const message = computed({
            get: () => props.modelValue,
            // No set() ?
        });

        return { message };
    }
}

In this example, the child component never 'emits' - yet the data is still updating in the parent component. This violates the "One-way" rule. Data must be propagated from child components using only emits and not via prop proxies.

The problem in here is that props.modelValue is reactive when arrives in the child component. One can verify this with the isReactive() helper. When it's passed through the computed() it retains that reactiveness and will continue to proxy updates through itself into the parent component.

A solution:

JSFiddle here

const { createApp, ref, computed } = Vue;

const child = {
    template: `<input v-model="message" type="text" />`,
    
    props: {
        modelValue: {
            type: Object,
            default: () => ({}),
        },
    },

    emits: ['update:modelValue'],

    setup(props, { emit }) {
        const message = computed({
           get: () => props.modelValue.test,
           set: (test) => emit('update:modelValue', ({...props.modelValue, test })),
        });
        
        return { message };
    }
};

createApp({
    components: { child },

    setup() {
        const message = ref({ test: 'Karamazov' });

        return { message };
    }
}).mount('#app');

The solution is three parts:

  1. The computed getter must not return the proxy object from the parent component. Once this happens you're in danger of violating the "one-way" rule [note 1]. In this example props.modelValue.test is a string so we're safe.

  2. The computed setter must emit the whole object, but again it must not be a reactive type. So we clone the modelValue using spread and include in the updated test field. This can also be achieved with Object.assign({}, props.modelValue, {test}) [note 2].

  3. The message variable in the parent component cannot be a reactive() and must be a ref(). When the v-model receives the newly emitted object the message variable is clobbered and no longer reactive [note 3]. Even with refs the props.modelValue will still fully reactive when it arrives in the child component, so the cloning steps are still important.

Alternatively:

I should also mention that values from computed() are not deeply reactive. As in, setting values on a computed object will not trigger the computed setter.

An alternate solution for passing the whole object through to your template:

setup(props, { emit }) {
    const message = reactive({...props.modelValue});
    watch(message, message => emit('update:modelValue', ({...message})));
    return { message };
}

In this, the whole message object will emit whenever the .test field is updated. E.g. <input v-model="message.test" />. This still obeys the "one-way" data rule because emits are the only way data is given to parent component.

Reasoning:

"One-way" data flow is important [4]. Consider this:

<child :modelValue="message"></child>

On a first (and a sensible) glance, this appears to pass data into 'child' but not out of 'child'. But, given a reactive object that is not handled by the child correctly, this will emit changes into my own component.

Observing this code I don't expect this behaviour so it's very important that the child component gets it right.

Notes:

[1]: Testing violations of the "one-way" rule are surprisingly simple. Remove any emit and if the parent receives updates - you've broken it. Or replacing v-model with v-bind also works.

[2]: Object.assign() and {...} spread are indeed different. But shouldn't affect our uses here.

[3]: I haven't found any clear documentation about this behaviour regarding reactive() and v-model. If anyone wants to chime in, that'd be great.

[4]: The Vue docs stress the importance of one-way bind. Evan himself (creator of Vue) even provides examples about how to use v-model with objects (in Vue 2, but the principles still apply).

I feel it's also important to note later in the same thread Evan suggests objects that are nested more than 1-level are considered misuse of v-model.

gwillz
  • 276
  • 5
  • 12
4

Should be pretty straight forward, and no computed is needed. See example below.

The messageObj was replaced with message in the child component for the emit to work (which would break due to case sensitivity in this demo)

const app = Vue.createApp({
  setup() {
    const message = Vue.reactive({ test: '123' , foo: "bark"});

    return {
      message,
    };
  }
})

app.component('Message', {
  props: {
    message: {
      type: Object,
      default: () => {},
    },
  },
  emits: ['update:message'],
  setup(props, { emit }) {
    const message = props.message;
    return { message };
  },
  
  template: document.querySelector('#t_child')
})

app.mount('#app')
<script src="https://unpkg.com/vue@3.0.2/dist/vue.global.prod.js"></script>

<fieldset>
  <div id="app">
    <h1>{{ message.test }} || {{ message.foo }}</h1>
    <fieldset><Message v-model:message="message"/></fieldset>
  </div>
</fieldset>

<template id="t_child">
  <label>
    <h4>{{message}}</h4>
    <input v-model="message.test" type="text" />
    <input v-model="message.foo" type="text" />
  </label>
</template>
Daniel
  • 34,125
  • 17
  • 102
  • 150
1

It turns out that 2 way binding of object properties with Vue 3 is even easier than demonstrated in any of the previous answers.

Parent Code (App.vue):

<script setup>
  import Controller from './components/Controller.vue';
  import { reactive } from 'vue'; 
  const object1 = reactive({name: "Bruce", age: 38});
  const object2 = reactive({name: "Alex", age: 6});
</script>

<template>
  <div>
    {{object1}}<br/>
    {{object2}}
    <Controller :my-object="object1"/>
    <Controller :my-object="object2"/>
  </div>
</template>

Component code (Controller.vue):

<script setup>
  import { computed } from 'vue'

  const props = defineProps({
    myObject: {
      type: Object,
      default: () => {}
    }
  })

  const name = computed({
    get () {
      return props.myObject.name
    },
    set (value) {
     props.myObject.name = value
    }
  })

  const age = computed({
    get () {
      return props.myObject.age
    },
    set (value) {
     props.myObject.age = parseInt(value)
    }
  })
</script>

<template>
  <div>
    <input v-model="name"/><br/>
    <input v-model="age" type="number"/>
  </div>
</template>

Explanation:

The <component :my-object="object1" /> syntax uses a : to tell Vue that we are passing an object (object1), rather than a string to the component and assigning it to property myObject. It turns out that when the child component receives this property, its reactivity is still intact. Therefore, as long as we don't mutate myObject itself, but instead only modify its properties, there is no need to emit any events or even pass it with as a property called v-model (we can call the property whatever we want). Instead the javascript proxy that the reactive keyword creates will do all the work tracking the changes and re-rendering it.

Some testing reveals that it is even possible to add new properties to the object or change deep properties and still maintain reactivity.

I am just a beginner with Vue, so there may be reasons why using this method are an anti-pattern, with unintended future consequences...

bruceceng
  • 1,844
  • 18
  • 23