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>
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>
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:
- 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. - 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>
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>
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)