Custom Component v-model attribute with Vue 3

Dan Pastori avatar
Dan Pastori January 26th, 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

<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

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

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:

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:

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

Support future content

The Ultimate Guide to Building APIs and Single-Page Applications with Laravel + VueJS + Capacitor book cover.

Psst... any earnings that we make off of our book is being reinvested to bringing you more content. If you like what you read, consider getting our book or get sweet perks by becoming a sponsor.

Written By Dan

Dan Pastori avatar Dan Pastori

Builder, creator, and maker. Dan Pastori is a Laravel certified developer with over 10 years experience in full stack development. When you aren't finding Dan exploring new techniques in programming, catch him at the beach or hiking in the National Parks.

Like this? Subscribe

We're privacy advocates. We will never spam you and we only want to send you emails that you actually want to receive. One-click unsubscribes are instantly honored.