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.

Note
Vue 3 has two different API styles: Options API and Composition API. In these examples I’m going to use the Composition API with <script setup>. For more details, check the official comparison of API styles.

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 more efficient to create a reusable component for checkboxes instead of repeating the same code throughout the application, especially when styling is involved.

However, this also means that we need to pass the checkbox value from outside of the component. By default, v-model uses a prop called modelValue and a custom event update:modelValue when the value changes. Our component needs to connect the incoming prop to the <input> element and emit the value of the input event.

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 component’s setup script only has one prop and one emit. We can’t use v-model to directly connect the modelValue prop to the input element since prop bindings are not editable. Instead, we need to connect the modelValue prop to the checked attribute and have the input event emit the checked state as update:modelValue.

The model value can be used in the component’s template to display the current state.

Now the component can be used in an application:

<script setup>
import { ref } from 'vue'
import FormCheckbox from './FormCheckbox.vue'
const checked = ref(true)
</script>

<template>
  <FormCheckbox v-model="checked"></FormCheckbox>
</template>
Warning

When using different <input> types make sure that you bind to the correct attribute and event value. Text inputs use value instead of checked!

<template>
  <input 
    type="text" 
    id="myTextbox" 
    :value="modelValue" 
    @input="$emit('update:modelValue', $event.target.value)"
  >
</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

When implementing v-model, it’s important to keep in mind that the v-model directive works by passing the value and event between the parent and child components. When working with a nested component structure, the v-model binding needs to be repeated in all middle layers to ensure that the value is passed correctly.

One scenario where this can come up is when working with Headless UI, which is an unstyled UI framework. It contains logic for menus, listboxes, switches, dialogs, etc. but leaves the styling up to the developer. These components accept a v-model binding, and if a custom styled component is needed, necessary model values and emits must be passed around 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 also 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>

Note how instead of using the v-model directive for the Switch component you must manually pass your own modelValue prop and bind the update event. In this case, the event already contains the needed value, so it can be returned directly.

I’ve copied the styling from the example in the documentation but here modelValue is used in place of the internal ref enabled to set the UI state.

Since Headless UI places a strong emphasis on accessibility, 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!

Toggle button example

Using multiple v-models

Multiple v-model definitions can be used, each with its own name, to bind multiple data values in a complex component.

One such use case could be a reusable sign-in UI component, where separating the UI from the logic makes it more reusable and flexible. This separation is especially useful when you’re building a component library with Storybook.js, as it allows for easy testing and documentation of the components.

Here’s the example sign-in 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>

Instead of using the default modelValue name, the props are defined as username and password in the script setup. Similarly, emits are defined for both of these props. With this configuration, the component does not have a default v-model binding.

The implementation is similar to the custom component example, but the prop and emit names are different. The emitted event value is also different since a text input control uses $event.target.value instead of the checked property that was used with the checkbox example.

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:

image-20230113140929630

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!

Subscribe to my newsletter

What’s new with PäksTech? Subscribe to receive occasional emails where I will sum up stuff that has happened at the blog and what may be coming next.

powered by TinyLetter | Privacy Policy