Custom Component v-model attribute with Vue 3

Dan Pastori

January 27th, 2022

The core of VueJS and why it's so awesome is the ease of making reactive interfaces. To watch input on an element you can use the v-model attribute. This attribute will automatically update when the user changes a value. However, when you start to create more advanced VueJS functionality, you will want to abstract that functionality into components that you can use throughout the app. You also want these complex components to be able to accept a v-model attribute and react to what the user enters.

In this tutorial, we will create a component that searches our transaction categories in Financial Freedom. This component will contain a decent amount of logic that makes sense to abstract into a re-usable piece that we will use in multiple places. When the user selects a category through a variety of methods, we will update the v-model attribute. This allows us to access the data from a parent component.

v-model Documentation

While this functionality is fully documented on VueJS Documentation, I wanted to make a more consumable version with an example. I feel this is a super powerful way to make re-usable elements for your app or as open source projects. Hopefully this quick tutorial helps a little bit!

What We Are Building

Just for a proof of concept, we are creating a separate VueJS component called CategorySelect.vue. In this component, we will be allowing the user to search through a list of provided categories, autocomplete the results that match, and select a category. These categories will be for financial transactions, such as "Groceries", "Gas", "Rent".

The reason we abstracted this functionality is so we can re-use this CategorySelect.vue component anywhere we import or add a transaction. This means that we need to allow v-model to bind to what the user selects.

If you are like me, and just want to see the final code, see below. Otherwise keep reading and I'll step you through the process. In the example we are also using InertiaJS. You will see reference to this.$page.props.categories which is a global array of the categories present in our app.

CategorySelect.vue

CategorySelect Component Implementation

<template>
    <div class="relative">
        <input type="text" 
              @focus="prepareInput()"
              @keypress="filter()"
              @keyup.down="incrementActiveIndex()"
              @keyup.up="decrementActiveIndex()"
              @keydown.enter.prevent="selectCategory()"
              v-model="search" 
              class="flex-1 min-w-0 block w-full px-3 py-2 rounded-md border focus:ring-blue-500 focus:border-blue-500 sm:text-sm border-gray-300"/>

        <div v-if="showResults" class="absolute max-h-60 w-96 overflow-y-scroll bg-white z-50 shadow-sm rounded-md border border-gray-300">
            <div class="p-2 group cursor-pointer flex items-center hover:bg-blue-600 hover:text-white"
                v-for="( category, categoryIdx ) in filteredCategories"
                v-bind:key="category.id"
                v-bind:class="{
                    'bg-blue-600': activeIndex == categoryIdx,
                    'text-white': activeIndex == categoryIdx     
                }"
                @click="selectCategory( categoryIdx )">
                    <svg v-show="category.parent_id"
                         v-bind:class="{
                            'fill-white': activeIndex == categoryIdx,
                            'fill-gray-500': activeIndex != categoryIdx 
                         }"
                         class="ml-5 mr-1 group-hover:fill-white" xmlns="http://www.w3.org/2000/svg" x="0px" y="0px" width="16" height="16" viewBox="0 0 32 32"><path d="M 5 5 L 5 22 L 24.0625 22 L 19.78125 26.28125 L 21.21875 27.71875 L 27.21875 21.71875 L 27.90625 21 L 27.21875 20.28125 L 21.21875 14.28125 L 19.78125 15.71875 L 24.0625 20 L 7 20 L 7 5 Z"></path></svg>
                    {{ category.name }}
                </div>
        </div>
    </div>
</template>

<script>
export default {
    props: {
        modelValue: String
    },

    emits: ['update:modelValue'],

    data(){
        return {
            search: '',
            showResults: false,
            filteredCategories: [],
            activeIndex: 0,
            selectedCategory: {}
        }
    },

    mounted(){
        this.filteredCategories = this.$page.props.categories
    },

    watch: {
        search(){
            // Handles deletion from the field
            if( this.search == '' ){
                this.selectedCategory = {};
                this.filter();
            }
        }
    },

    methods: {
        selectCategory( index = null ){
            if( !index ){
                this.selectedCategory = this.filteredCategories[ this.activeIndex ];
            }else{
                this.selectedCategory = this.filteredCategories[ index ];
            }

            this.search = this.selectedCategory.name;
            this.filteredCategories = [];
            this.activeIndex = 0;
            this.showResults = false;
            this.$emit('update:modelValue', this.selectedCategory.id);
        },

        prepareInput(){
            this.activeIndex = 0;
            this.showResults = true;
            this.search = '';
            this.filter();
            this.$emit('update:modelValue', "");
        },

        filter(){
            this.activeIndex = 0;

            if( this.search != '' ){
                this.filteredCategories = this.filteredCategories.filter( function( category ){
                    let name = category.name.toLowerCase();
                    return name.search( this.search.toLowerCase() ) > -1;
                }.bind(this));
            }else{
                this.filteredCategories = this.$page.props.categories;
            }
        },

        incrementActiveIndex(){
            if( this.activeIndex + 1 < this.filteredCategories.length ){
                this.activeIndex = this.activeIndex + 1;
            }
        },

        decrementActiveIndex(){
            if( this.activeIndex - 1 >= 0 ){
                this.activeIndex--;
            }
        },
    }
}
</script>

Using The Component

Component Usage Example

<category-select v-model="transaction.category"/>

Step 1: Add modelValue Prop to Component

There are only three steps to make allow for v-model usage on a custom component. Step 1 is to add the modelValue prop the type of a String to your component:

Add the model value

props: {
    modelValue: String
},

This will allow you to accept the v-model attribute on your custom component.

Step 2: Register The Events Emitted By Your Component

Next, add the following property:

Registering Component Events

emits: ['update:modelValue'],

This property registers what events are emitted and listened to by the parent component. When the property we want to set as the model changes, we will emit the update:modelValue event.

Step 3: Emit Event When Value Updates

Finally, whenever we update the value, in this case when the user selects the appropriate category, we need to emit the update:modelValue event:

Emitting Update Event

this.$emit('update:modelValue', this.selectedCategory.id);

This event will update the model property so the parent can two-way sync and read the value of the component. Now you can encapsulate complex code and logic into reusuable components and directly bind the value from a parent component.

Conclusion

Even though this is a smaller tutorial, it's extremely helpful to order to break out functionality into smaller parts. Especially with complex form fields and selectors. Let me know if you have any questions by leaving a comment or reaching out to me on Twitter (@danpastori).

Want to work together?

Professional developers choose Server Side Up to ship quality applications without surrendering control. Explore our tools and resources or work directly with us.

Join our community

We're a community of 3,000+ members help each other level up our development skills.

Platinum Sponsors

Active Discord Members

We help each other through the challenges and share our knowledge when we learn something cool.

Stars on GitHub

Our community is active and growing.

Newsletter Subscribers

We send periodic updates what we're learning and what new tools are available. No spam. No BS.

Sign up for our newsletter

Be the first to know about our latest releases and product updates.

    Privacy first. No spam. No sharing. Just updates.