Vue.js 3 v-model two-way data binding for Dummies

July 14th, 2022 by Philip Iezzi 7 min read
cover image

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 independent v-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!