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:
defineModel
is a macro that creates and syncs a prop with the parent state and is accessible in the child as themodel
refdefineModel
is basically a syntactic sugar for thedefineProps
anddefineEmits
with theupdate:modelValue
event, under the hood it works the same way as before Vue 3.4- We don’t have to define the emit event,
defineModel
does it for us - We can directly change the
model
ref, and it will be reflected in the parent component - Directly change the
model
ref will emit theupdate:modelValue
event 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>
model
value is mutated - we have to differentiate between value change and mutation. Modifying the propmodel.prop1
we 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:modelValue
event is not emitted - following from the above point, prop mutation isn’t considered as a model value change and theupdate:modelValue
event is not emitted. This is very error-prone and not consistent as thev-model
behavior.-
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:modelValue
event - to fix the above issue we have to define theupdate:modelValue
event and emit it manually each time we mutate the object props. Now we get theupdate:model-value
on 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
defineModel
macro and the mutation by reference. Use in complex cases where thev-model
may 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.