Vue.js 3 v-model two-way data binding for Dummies
Vue v-model
is a directive that creates a two-way data binding between a value in our template and a value in our data properties, while v-bind
only binds data one way. A common use case for using v-model is when designing forms and inputs.
While the core concept of v-model
is quite straightforward, you might struggle with it when building advanced custom form components. Personally, this was my main pain point when learning Vue, especially since there are so many different ways this can be handled and tutorials diverge quite a bit.
Vue's official Components In-Depth > Events > Usage with v-model
documentation only covers the most basic usage without providing any advanced examples. So I put together some use cases for you to learn more about v-model
. The main question is: How can I make that custom component reusable so that it can be used as a form component with two-way data binding?
v-model
directive explained
Vue's v-model
directive does the following under the hood:
<MyChild v-model="someRef" />
<!-- same as -->
<MyChild
:modelValue="someRef"
@update:modelValue="someRef = $event"
/>
By default, the following applies to v-model
:
- default prop name is
modelValue
- default event is
update:modelValue
This prop and event name will then be available in your child component.
The default prop and event name could be changed by appending the name with a colon:
v-model:name
, but let's stick with the default! Changing the name would only be needed if you would like to assign multiple independentv-model
directives on the same component tag, which is very rarely used.
So, in every custom component which should support two-way data binding you need to implement the following:
- declare
modelValue
as a prop - emit
update:modelValue
event when the input changes
Let's dive into a selection of 3 use cases which all use the recommended Vue 3 Composition API <script setup>
syntax.
CustomInput
form component
In this example, we are building a CustomInput
form component that wraps the native <input>
HTML element. It could then be used like this from our parent component:
<script setup>
import { ref } from 'vue'
import CustomInput from './CustomInput.vue'
const fullname = ref('')
</script>
<template>
<form>
<CustomInput v-model="fullname" />
</form>
</template>
Our child component (basic CustomInput
form component) looks like this:
<!-- CustomInput.vue -->
<script setup>
defineProps(['modelValue'])
defineEmits(['update:modelValue'])
</script>
<template>
<input
:value="modelValue"
@input="$emit('update:modelValue', $event.target.value)"
/>
</template>
In this example, the data we are binding to our event is $event.target.value
which is the actual String
value of this input field.
Remember: Native HTML elements always fire Events, so you'll never get a primitive (like e.g. a Boolean
or a String
) as $event
. That's why you need to retrieve the effective input element's value with $event.target.value
in this case.
CustomSwitch
(Toggle) form component
Let's create a slightly more sophisticated custom Switch (Toggle) form component, using Headless UI Switch
component. Please note that the official Headless UI documentation provides us with a basic example where they use an enabled
ref to store the current state:
<script setup>
import { ref } from 'vue'
const enabled = ref(false)
</script>
This extra ref is not needed inside our custom component, as the Switch
component itself fires an event with a primitive Boolean
that represents the current on/off state (see Switch
Component API documentation, where v-model
supports a Boolean
). So, $event
is simply the Boolean
and we can pass it directly to our parent component with @update:modelValue="$emit('update:modelValue', $event)"
.
Side-note: In the example below, we are going to use the newer syntax without depending on the "magic" $event
variable, so this is exactly the same in Vue 3 (see Component Events > Usage with v-model
):
<Switch
@update:modelValue="$emit('update:modelValue', $event)"
/>
<!-- same as -->
<Switch
@update:modelValue="(newValue) => $emit('update:modelValue', newValue)"
/>
So our fully working (and minimally styled) CustomSwitch
component looks like this:
<!-- CustomSwitch.vue -->
<script setup>
import { Switch } from '@headlessui/vue'
defineProps({
modelValue: {
type: Boolean,
default: false,
},
label: {
type: String,
default: '',
},
})
defineEmits(['update:modelValue'])
</script>
<template>
<Switch
:class="modelValue ? 'bg-primary-600' : 'bg-gray-300'"
class="relative inline-flex h-6 w-11 items-center rounded-full"
:modelValue="modelValue"
@update:modelValue="(newValue) => $emit('update:modelValue', newValue)"
>
<span class="sr-only">{{ label }}</span>
<span
:class="modelValue ? 'translate-x-5' : 'translate-x-0'"
class="inline-block h-5 w-5 transform rounded-full bg-white"
/>
</Switch>
</template>
Slot props:
Please note, in this example, we use the modelValue
prop directly for the current state of the Switch/Toggle. The Headless UI Switch
component would offer a checked
slot prop for that as well, but this would only be available inside the Switch
component tag and not in e.g. any direct property of the Switch
tag itself (e.g. class
, where we would still depend on either modelValue
or some other ref that represents the current state). The following could be done, but just makes our example more complex, as we would use checked
slot prop in addition to the actual modelValue
:
<template>
<Switch
v-slot="{ checked }"
:class="modelValue ? 'bg-primary-600' : 'bg-gray-300'"
...
>
<span :class="checked ? 'translate-x-5' : 'translate-x-0'" />
</Switch>
</template>
Immutable props:
And why can't we directly bind the modelValue
to the Switch
component using v-model="modelValue"
inside our CustomSwitch
component? Read this: Props: One-Way Data Flow
All props form a one-way-down binding between the child property and the parent one: when the parent property updates, it will flow down to the child, but not the other way around. This prevents child components from accidentally mutating the parent's state, which can make your app's data flow harder to understand. (...) This means you should not attempt to mutate a prop inside a child component. If you do, Vue will warn you in the console
So, never try to mutate a property in your custom component! Always work around it by only reading the prop value and firing events to pass the changed value back to the parent component, as we did in above example.
CustomListbox
(Select) for component
Let's dive into another slightly more complex example of v-model
two-way data binding, using Headless UI Listbox
component. We can use this component to build custom, accessible select menus.
Options are passed in an object notation as options
prop to our custom component:
<template>
<ListboxSelect
v-model="form.person"
label="Person"
:options="[
{ id: 1, name: 'Danny MacAskill' },
{ id: 2, name: 'Stefano Meloni' },
{ id: 3, name: 'Toni Peperoni' },
]"
/>
</template>
In our CustomListbox
component, on the top level Listbox
component, we store the current option in a selectedOption
ref, using v-model
. As the current option is an object (e.g. {id: 1, name: 'Danny MacAskill'}
), we need to extract the id from it and only pass this Number
to the parent component.
So, in below example, we need to store the currently selected option anyway in a new ref selectedOption
. Like this, we can make use of v-model
on Listbox
component and then watch that ref for changes. On every change event, we emit the update:modelValue
event and only extract the id
from the selected option. Here's our fully working (yet unstyled) CustomListbox
component:
<!-- CustomListbox.vue -->
<script setup>
import { ref, watch } from 'vue'
import { Listbox, ListboxLabel, ListboxButton, ListboxOptions, ListboxOption } from '@headlessui/vue'
const props = defineProps({
modelValue: {
type: Number,
default: null,
},
label: {
type: String,
default: '',
},
options: {
type: Array,
required: true,
},
})
const emit = defineEmits(['update:modelValue'])
const selectedOption = ref(props.options[0] ?? null)
watch(selectedOption, (option) => emit('update:modelValue', option.id), { immediate: true })
</script>
<template>
<Listbox v-model="selectedOption">
<ListboxLabel>{{ label }}:</ListboxLabel>
<ListboxButton>{{ selectedOption.name }}</ListboxButton>
<ListboxOptions>
<ListboxOption v-for="option in options" :key="option" :value="option">
{{ option.name }}
</ListboxOption>
</ListboxOptions>
</Listbox>
</template>
I hope these examples gave you a better feeling for v-model
.
Now, enjoy the power of magic reactivity by two-way data binding. Happy coding!