Implement v-model in Vue.js
Janne Kemppainen |In Vue, the v-model
directive is used to create a binding between an input or a component, and a data reference. This makes it possible to update the state of a value that is passed to a component. But how can you implement it yourself, especially if the value needs to be passed through a nested component?
You might also be interested in my earlier post Create a Web App with Vue 3, Tailwind CSS and Storybook.
The v-model API from a user’s perspective
Using a component that implements the v-model
directive is simple. You only need to pass the variable reference through the v-model
attribute, and the state will be kept in sync automatically.
Here’s an example that uses a checkbox input:
<template>
<div>
<input type="checkbox" id="myCheckbox" v-model="checked">
<label for="myCheckbox">{{ checked }}</label>
</div>
</template>
<script setup>
import { ref } from 'vue'
const checked = ref(true)
</script>
When you check and uncheck the box the text label changes between true
and false
. Couldn’t get much simpler than that!
Implementing v-model for a custom component
It’s not very practical to repeat the checkbox implementation everywhere in an application, especially when styling is involved. It’s better to create a shared component that can be reused.
But now we need to be able to somehow pass the reference from the outside of the component as using an internal value that we can’t access would be quite pointless.
By default, the v-model
directive expects a prop called modelValue
, and a custom event update:modelValue
when the value is updated. Therefore, our component needs to bind the incoming prop to the <input>
element and forward the value of an HTML input event when one occurs.
Let’s translate this to a Vue component:
<template>
<div>
<input
type="checkbox"
id="myCheckbox"
:checked="modelValue"
@input="$emit('update:modelValue', $event.target.checked)"
>
<label for="myCheckbox">{{ modelValue }}</label>
</div>
</template>
<script setup>
defineProps(['modelValue'])
defineEmits(["update:modelValue"])
</script>
The setup script only defines one prop and one emit, modelValue
and update:modelValue
, respectively. We cannot use v-model
to bind the modelValue
prop directly to the input element since prop bindings are not writable. Instead, the prop value has to be bound to the checked
attribute, and the input
callback should be configured to emit the checked state.
The model value can naturally be used normally within the template for rendering.
Now the component can be used from an application:
<script setup>
import FormCheckbox from './FormCheckbox.vue'
import { ref } from 'vue'
const checked = ref(true)
</script>
<template>
<FormCheckbox v-model="checked"></FormCheckbox>
</template>
If you’re using TypeScript you’ll have to type cast the event target before you can access the value itself:
@input="$emit('update:modelValue', ($event.target as HTMLInputElement).checked)"
Nesting v-model
So now you know how to implement v-model
. What if you need to go further than one level? Well, in that case you must repeat the implementation in all middle layers.
One such use case can come up with Headless UI which is an unstyled UI framework. It contains the logic for menus, listboxes, switches, dialogs, and so on, but the styling is totally up to you. These components accept a v-model
binding, and if we want to create a new component with our custom style, we also need to pass around the needed model values and emits to make it all work.
This example is a toggle component that wraps switch from Headless UI. It’s adapted from the basic example in the switch documentation and uses Tailwind CSS for styling. My previous post Create a Web App with Vue 3, Tailwind CSS and Storybook covers setting up Tailwind CSS with Vue.
Naturally, this example requires the @headlessui/vue
package to work.
<template>
<Switch
:modelValue="modelValue"
@update:modelValue="$emit('update:modelValue', $event)"
:class="modelValue ? 'bg-blue-600' : 'bg-gray-200'"
class="relative inline-flex h-6 w-11 items-center rounded-full"
>
<span class="sr-only">{{ description }}</span>
<span
:class="modelValue ? 'translate-x-6' : 'translate-x-1'"
class="inline-block h-4 w-4 transform rounded-full bg-white transition"
/>
</Switch>
</template>
<script setup>
import { Switch } from "@headlessui/vue";
defineProps(["modelValue", "description"]);
defineEmits(["update:modelValue"]);
</script>
Notice how instead of using v-model
for the Switch
component we need to manually pass our own modelValue
prop and bind the update event. This time, the event already contains the needed value, so we must return it directly!
I’ve copied the styling from the example documentation but here modelValue
is used in place of the internal ref enabled
.
Since Headless UI also takes accessibility seriously, I’ve included the screen reader description from the example and made it configurable via a property.
Here’s a simple app that uses the toggle to display a different text based on the toggle status.
<script setup>
import { ref } from "vue";
import FormToggle from "./FormToggle.vue";
const toggled = ref(true);
</script>
<template>
<main class="container mx-auto">
<div class="mt-4 mx-4">
<FormToggle v-model="toggled" description="My toggle" />
<p v-if="toggled">Toggle is on</p>
<p v-else>Toggle is off</p>
</div>
</main>
</template>
And this is how the toggle looks in action!
Using multiple v-models
Just like with slots, you can have multiple v-model
definitions, each with their own name. This is obviously useful when we want to bind many data values in a complex component.
One such use case could be a reusable sign in UI component. You wouldn’t want to include the actual sign in logic in a component that could be reused in other projects. This separation of UI from the application logic is especially useful when you’re building a component library with Storybook.js.
Here’s the example component. I have highlighted the important lines.
<template>
<div class="container mt-4">
<h1 class="text-xl font-bold mb-2">Sign In</h1>
<label class="block font-bold mb-1" for="username">Username</label>
<input
id="username"
type="text"
class="border px-2 py-1 rounded"
:value="username"
@input="$emit('update:username', $event.target.value)"
/>
<label class="block font-bold mb-1" for="password">Password</label>
<input
id="password"
type="password"
class="border px-2 py-1 rounded"
:value="password"
@input="$emit('update:password', $event.target.value)"
/>
<div class="mt-4">
<button
class="
bg-blue-500
hover:bg-blue-700
text-white
font-bold
py-2
px-4
rounded
"
>
Sign In
</button>
</div>
</div>
</template>
<script setup>
defineProps(["username", "password"]);
defineEmits(["update:username", "update:password"]);
</script>
The props are again defined in the script setup. This time we don’t use the modelValue
name, but use username
and password
instead. Similarly, we define emits for both of these props. With this configuration our component doesn’t have a default v-model
binding.
The implementation stays basically the same as we had in the custom component example but the prop and emit names are different. The emitted event value is also different since a text input control needs to use $event.target.value
instead of the checked
property that we used with the checkbox.
For the sake of this example I’ve left out the button event handling logic.
Now let’s use the component in an application.
<script setup>
import { ref } from "vue";
import SigninForm from "./SigninForm.vue";
const username = ref("");
const password = ref("");
</script>
<template>
<main class="container mx-auto">
<SigninForm v-model:username="username" v-model:password="password" />
<p>Username is "{{ username }}"</p>
<p>Password is "{{ password }}"</p>
</main>
</template>
The named reference bindings are v-model:username
and v-model:password
. When the form values are updated the changes are also reflected in the paragraphs that show the values.
This is how the page looks:
It really is that easy.
Conclusion
In this article we took a look at the Vue v-model
and implemented it a few times in different contexts. Hopefully this post gave you the confidence to start your own creations!
Previous post
Combine Multiple Filter Conditions in Python