Vue defineModel: greate and terrible
Vue is a UI library that supports two-way binding out of the box. That means you can write components which not only can accept readonly props but also are illegible to emit events to parent components to update the props. Two-way binding has a long history in Vue and makes a lot of transformations until Vue 3.4. Anyway, it still has some pitfalls, can be a source of bugs and confusion and is one of the most controversial topics in Vue.
Vue 3.4 v-model
But let’s start with the good parts. Vue 3.4 introduced a new way to define v-model in the components.
With a defineModel macro
we now have to write significantly less code to create a component with v-model. Let’s see an example:
<template>
<v-textfield v-model="model"/>
</template>
<script setup lang="ts">
const model = defineModel<string>({
required: true,
});
</script>
And now we can use our custom MyTextInput.vue component with v-model in the parent component,
just like before Vue 3.4, myValue will be in sync with the model in the child component:
<template>
<my-text-input v-model="myValue" @update:modelValue=" do stuff with updated value"/>
</template>
<script setup lang="ts">
import MyTextInput from '@/components/MyTextInput.vue';
import {ref} from 'vue';
const myValue = ref<string>('');
</script>
MyTextInput.vue before Vue 3.4 to feel the difference
<template>
<v-textfield v-model="model"/>
</template>
<script setup lang="ts">
const {modelValue} = defineProps<{
modelValue: string;
}>();
const emit = defineEmits<{
"update:modelValue": [string];
}>();
const computedModelValue = computed({
get: () => modelValue,
set: (val) => emit("update:modelValue", val),
});
watch(
() => modelValue,
() => {
computedModelValue.value = modelValue;
}
);
</script>
Pretty nice, right! You should note a few things here:
defineModelis a macro that creates and syncs a prop with the parent state and is accessible in the child as themodelrefdefineModelis basically a syntactic sugar for thedefinePropsanddefineEmitswith theupdate:modelValueevent, under the hood it works the same way as before Vue 3.4- We don’t have to define the emit event,
defineModeldoes it for us - We can directly change the
modelref, and it will be reflected in the parent component - Directly change the
modelref will emit theupdate:modelValueevent to the parent component
Objects and defineModel
But what if we want to use two-way binding with objects? Mostly you will write custom v-model for rather complex
components
like forms, color pickers, or other components that have a complex state. Or you might want to write a component for an
array v-model.
You know, in JS anything is an object and objects are copied by reference. This is where defineModel starts to be
a bit tricky.
Imagine below is your brand-new form component written with defineModel. Let’s see which cascade of issue the below
code produces and let us try to resolve it:
<template>
<v-textfield v-model="model.prop1"/>
<v-textfield v-model="model.prop2"/>
</template>
<script setup lang="ts">
type Model = { prop: string, prop2: string }
const model = defineModel<Model>({
required: true,
});
</script>
modelvalue is mutated - we have to differentiate between value change and mutation. Modifying the propmodel.prop1we mutate the object by reference. In general, we mutate the object passed in by the parent “avoiding” Vue internal mechanisms. Before Vue 3.4, Vue would have insulted us for something like this in theconsole.warn. Now it seems to be intended behavior according to the corresponding RFC .update:modelValueevent is not emitted - following from the above point, prop mutation isn’t considered as a model value change and theupdate:modelValueevent is not emitted. This is very error-prone and not consistent as thev-modelbehavior.-
changing object value is dangerous - to fix the above issue we have to change the object value like this:
model.value = { ...model.value, prop1: 'new value' }Apart from the limited Developer Experience, this is also very error-prone. If your component is more complex and the object is changed like this in different places in the same tick, the chances are high that some changes may be overwritten as we are replacing the whole object each time.
- define
update:modelValueevent - to fix the above issue we have to define theupdate:modelValueevent and emit it manually each time we mutate the object props. Now we get theupdate:model-valueon parent, but still mutating the object by reference.const emit = defineEmits<{ "update:modelValue": [Model]; }>(); ... model.value.prop1 = 'new value'; // still have to define the event manually, // kinda loosing the advantages of defineModel macro emit('update:modelValue', model.value);
- define
-
If you want to experience the issues cascade on your own, feel free to use this play ground. There you can get a few practical examples.
It must be mentioned, the issues covered above are not exclusive to the defineModel macro.
They also apply to the v-model and reactivity in general bevor Vue 3.4.
The defineModel macro just adds a new layer of abstraction
and covers some important pitfalls we developers should be aware of.
The recipe for two-way binding
- Primitives — just go for it, anything works like expected
- Objects/arrays
- 🚫
model.value.prop1 = "New value"/model.value.push("New value")- bad, mutated by reference, no event emitted, avoid that - ❕
model.value = { ...model.value, prop1: "New value" }/model.value = []- better compromise, value is changed, event emitted. Should be your choice in most cases. But, enjoy with caution where you are sure this will not cause any race conditions updating the props - ❕mutate value and emit event manually - can’t always recommend it due to verbosity, the loos of ergonomics of
defineModelmacro and the mutation by reference. Use in complex cases where thev-modelmay be updated (* changed by value*) simultaneously in different places in one tick
- 🚫
One last edge case that might be a source of hours of debugging.
Value changes on v-model in the component accepting and handling the v-model are only applied in the next tick!
Meaning you cant directly work with value newly assigned to the v-model in the same tick.
const onModelChange = (newValue: string) => {
model.value = { prop1: newValue };
doSomethingWithNewModelValue(model.value) // 🚫 this will not work as expected, model.value is not yet updated and still holds the old value
}
And that’s actually a good thing,
it’s a signal that the v-model works as specified and Vue reactivity takes over the control
first emitting the event to the parent and then updating the value in the child component.
When crafting components with custom v-model, it’s important to keep reactivity quirks in mind to maintain a
healthy and predictable codebase. There’s no silver bullet solution for two-way binding. But with this
recipe, you’ll dodge the common pitfalls and make your code more robust.