DEV Community

Cover image for How to use an object with v-model in Vue
Giannis Koutsaftakis
Giannis Koutsaftakis

Posted on

How to use an object with v-model in Vue

We all know and love v-model in Vue. It's the directive that enables two-way binding in Vue components. When manually implementing v-model for a custom component, here’s how it usually looks:

<script setup>
const props = defineProps(['modelValue'])
const emit = defineEmits(['update:modelValue'])
</script>

<template>
  <input
    :value="props.modelValue"
    @input="emit('update:modelValue', $event.target.value)"
  />
</template>
Enter fullscreen mode Exit fullscreen mode

Notice that we don’t mutate the value of the modelValue prop inside the component. Instead, we emit the updated value back to the parent component, where the actual mutation happens. This is for a good reason: child components shouldn’t affect the parent’s state because it complicates data flow and makes debugging harder.

As stated in the Vue docs, you should not mutate a prop inside a child component. If you do, Vue will issue a warning in the console.

What about objects?
Objects and arrays are a special case in JavaScript because they are passed by reference. This means a component can directly mutate the nested properties of an object prop. However, Vue doesn’t issue warnings for mutations happening in nested object properties (it would incur a performance penalty to track these mutations). As a result, such unintended changes can lead to subtle and hard-to-debug issues in our app.

Most of the time, we use primitive values for v-model. However, in some cases, like building form components, we might need a custom v-model that works with objects. This raises an important question:

How do we implement a custom v-model for objects without falling into these pitfalls?

Exploring the problem

A first approach would be to use a writable computed property or the defineModel helper. However, both of these solutions have a significant drawback: they directly mutate the original object, which defeats the purpose of maintaining a clean data flow.

To illustrate the issue, let’s look at an example of a "form" component. This component is designed to emit an updated copy of the object back to the parent whenever a value in the form changes. We'll attempt to implement this using a writable computed property.

In this example writable computed still mutates the original object.

<script setup lang="ts">
import { computed } from 'vue';
import { cloneDeep } from 'lodash-es';

type Props = {
  modelValue: { name: string; email: string; };
};

const props = withDefaults(defineProps<Props>(), {
  modelValue: () => ({ name: '', email: '' }),
});

const emit = defineEmits<{
  'update:modelValue': [value: Props['modelValue']];
}>();


const formData = computed({
  // The object that gets returned from the getter is still mutable
  get() {
    return props.modelValue;
  },
  // Commenting out the setter still mutates the prop
  set(newValue) {
    emit('update:modelValue', cloneDeep(newValue));
  },
});
</script>
Enter fullscreen mode Exit fullscreen mode

This doesn't work as intended because the object that gets returned from the getter is still mutable, leading to unintended mutations of the original object.

The same thing happens with defineModel. Since update:modelValue is not emitted from the component and the object properties are mutated without any warning.

The solution

The "Vue way" to handle this scenario is to use an internal reactive value for the object and implement two watchers:

  1. A watcher to monitor changes to the modelValue prop and update the internal value. This ensures the internal state reflects the latest prop value passed from the parent.
  2. A watcher to observe changes in the internal value. When the internal value is updated, it will emit a fresh, cloned version of the object back to the parent component to avoid directly mutating the original object.

To prevent an endless feedback loop between these watchers, we need to ensure that updates to the modelValue prop do not unintentionally re-trigger the watcher on the internal value.

<script setup lang="ts">
import { ref, watch, nextTick } from 'vue';
import { cloneDeep } from 'lodash-es';

type Props = {
  modelValue: { name: string; email: string; };
};

const props = withDefaults(defineProps<Props>(), {
  modelValue: () => ({ name: '', email: '' }),
});

const formData = ref<Props['modelValue']>(defaultFormData);

/**
 * We need `isUpdating` as a guard to prevent recursive updates from happening.
 * [Error] Maximum recursive updates exceeded in component <Component>. This means you have a reactive effect
 * that is mutating its own dependencies and thus recursively triggering itself. Possible sources include
 * component template, render function, updated hook or watcher source function.
 */
let isUpdating = false;

const emit = defineEmits<{
  'update:modelValue': [value: Props['modelValue']];
}>();

// Watch props.modelValue for data updates coming from outside
watch(
  () => props.modelValue,
  (nV) => {
    if (!isUpdating) {
      isUpdating = true;
      formData.value = cloneDeep(nV);
      nextTick(() => {
        isUpdating = false;
      });
    }
  },
  { deep: true, immediate: true }
);

// Watch local formData to emit changes back to the parent component
watch(
  formData,
  (nV) => {
    if (!isUpdating && nV !== props.modelValue) {
      emit('update:modelValue', cloneDeep(nV));
    }
  },
  { deep: true }
);
</script>
Enter fullscreen mode Exit fullscreen mode

I know what you are thinking: "That's a lot of boilerplate!". Let's see how we can simplify this further.

Simplifying the solution with VueUse

Extracting this logic into a reusable composable is a great way to streamline the process. But here’s the good news: we don’t even need to do that! The useVModel composable from VueUse handles this for us!

VueUse is a powerful utility library for Vue, often referred to as the “Swiss army knife” of composition utilities. It’s fully treeshakable, so we can use only what we need without worrying about bloating our bundle size.

Here’s how our previous example looks when refactored to use useVModel:

<script setup lang="ts">
import { useVModel } from '@vueuse/core';
import { cloneDeep } from 'lodash-es';

type Props = {
  modelValue: { name: string; email: string; };
};

const props = withDefaults(defineProps<Props>(), {
  modelValue: () => ({ name: '', email: '' }),
});

const emit = defineEmits<{
  'update:modelValue': [value: Props['modelValue']];
}>();

// We need the `{ passive: true, deep: true }` options otherwise the prop will be mutated.
const form = useVModel(props, 'modelValue', emit, { clone: cloneDeep, passive: true, deep: true });
</script>
Enter fullscreen mode Exit fullscreen mode

So much cleaner!

And that’s it! We’ve explored how to properly use an object with v-model in Vue without directly mutating it from the child component. By using watchers or leveraging the power of composables like useVModel from VueUse, we can maintain clean and predictable state management in our app.

Ηere’s a Stackblitz link with all the examples from this article. Feel free to explore and experiment.

Thanks for reading, and happy coding!

Top comments (0)