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 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>
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

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!

Toggle button example

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:

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