Computed with v-model
INFO
This page has not yet been updated to cover the defineModel
macro, which was added in Vue 3.4. The techniques described here should still work, but in some cases it might be better to use defineModel
instead.
The principle of one-way data flow, with 'props down, events up', is just an extension of the idea that data should only be modified by its owner. This same idea can be extended to other scenarios, such as using Pinia, where the store is considered the owner of the data and so it should only be mutated by the store.
This causes problems when working with v-model
, which attempts to modify the value directly. One way we can address this is by using computed()
with get
and set
.
There are more complete examples for Checkbox and Radio components, but to reduce it down to the essentials with an <input>
:
<input v-model="inputValue">
With:
const inputValue = computed({
get () {
// return the current value
},
set (newValue) {
// Tell the data's owner to update the value
}
})
So for a prop passed down from the parent component we might do something like this:
const inputValue = computed({
get: () => props.title,
set: newValue => emit('update:title', newValue)
})
Using an event with a name of the form update:propName
allows it to be used with v-model
on the parent. The default prop name for this would be modelValue
. As such, the technique described here is the standard way to 'pass on' a v-model
from a component's parent to one of its children.
A similar approach would work for updating data via a Pinia action:
const store = useMyStore()
const inputValue = computed({
get: () => store.title,
set: newValue => store.updateTitle(newValue)
})
Libraries
This pattern is so common that it can be found in composable libraries:
Alternatives
It is possible to achieve something similar by avoiding the use of v-model
on the child and splitting it up into a prop and event instead. e.g.:
<input :value="value" @input="$emit('update:value', $event.target.value)">
This may be tempting, but it does move more logic into the template, which is usually regarded as a bad thing. It's also worth noting that using v-model
on native elements, such as <input>
and <select>
, actually adds some extra functionality that you won't get if you implement your own event listeners. e.g.:
- For text inputs,
v-model
adds special handling for IME composition. - For radios,
v-model
supports the use of non-string values for thevalue
. It also makes managing thechecked
option much simpler. - For checkboxes,
v-model
adds support for handlingtrue-value
,false-value
and the use of avalue
in conjunction with arrays and sets. As with radios it makes managing thechecked
option much simpler for non-boolean cases. - For
<select>
, it gives similar benefits to either radios and checkboxes, depending on whether themultiple
attribute is included.
These potential problems don't apply when using v-model
on components, so splitting it up into a prop/event pair is less fraught, but consistently sticking to using a computed
comes with very little risk and tends to be easier to maintain.
Advanced usage - proxying objects
A less common scenario involves passing a large object of field values to a form component:
<template>
<user-edit-form v-model="user" />
<pre>Bound value: {{ user }}</pre>
</template>
<script setup>
import { ref } from 'vue'
import UserEditForm from './user-edit-form.vue'
const user = ref({
firstName: 'Adam',
lastName: 'Bell',
city: 'Copenhagen',
country: 'Denmark',
email: 'exp@s.com',
phone: 'N/A'
})
</script>
Inside user-edit-form
we want to provide inputs for each of these 6 properties. But having to write 6 separate computed
values, one for each input, will quickly get annoying.
One trick to cut down on the boilerplate is to use a JS Proxy
to do the sleight-of-hand for the reading and writing of properties instead of a computed
. We'll still use a single computed
to ensure everything stays reactive, but one is all we need:
<!-- user-edit-form.vue -->
<template>
<form>
<input v-model="model.firstName">
<input v-model="model.lastName">
<input v-model="model.city">
<input v-model="model.country">
<input v-model="model.email">
<input v-model="model.phone">
</form>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
modelValue: {
required: true,
type: Object
}
})
const emit = defineEmits(['update:modelValue'])
const model = computed(() => {
return new Proxy(props.modelValue, {
set (obj, key, value) {
emit('update:modelValue', { ...obj, [key]: value })
return true
}
})
})
</script>
Putting that all together gives:
While it might look a bit fiddly if you aren't used to working with a Proxy
, most of this code is very reusable and can be hidden away behind a utility function. We could create a similar utility function for performing the same bit of trickery with an object coming from a store.
The approach does violate another best practice. The usual recommendation is to avoid mutating the properties of an object returned from a computed
, as they're considered transient. However, we're breaking that rule knowingly here as mutating those properties is the whole point of the approach.
In theory, it is possible to extend this idea to work with nested objects, though it quickly gets unwieldy trying to make all the relevant copies. It's probably better to rethink your approach in that scenario.
Another possible extension is to combine this approach with the earlier strategy of using computed
with get
and set
. This gives us something quite powerful, with the option to either replace individual properties or replace the whole object:
const model = computed({
get () {
return new Proxy(props.modelValue, {
set (obj, key, value) {
model.value = { ...obj, [key]: value }
return true
}
})
},
set (newValue) {
emit('update:modelValue', newValue)
}
})
With this version we can assign model.value = something
or model.value.firstName = something
and in either case it will be magically converted into an event. That allows for both v-model="model"
and v-model="model.firstName"
, whichever one we need.