DEV Community

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

Posted on • Edited on

9

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.

ℹ️ Throughout this article, I’ve used cloneDeep from lodash-es as an example of deep cloning objects. However, you can use any deep cloning method that suits your needs, such as structuredClone (built into modern browsers) or other utility libraries. The key takeaway is that cloning helps prevent unintended mutations when working with v-model and objects. Choose the approach that best fits your project!

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!

Sentry blog image

How I fixed 20 seconds of lag for every user in just 20 minutes.

Our AI agent was running 10-20 seconds slower than it should, impacting both our own developers and our early adopters. See how I used Sentry Profiling to fix it in record time.

Read more

Top comments (0)