Using v-model on Nested Vue Components

Written By Jesse Schutt
Posted on

Building a custom component that operates on chunks of data is easier than you may think. It involves understanding how Vue handles the input/update cycle and making a few changes to your component. Let’s start by taking a look at one approach for turning a standard component into a “controlled” component and then figure out how to nest them.

Setting the Stage

Say we have a simple form that accepts user details, such as name, email, and address:

// Form.vue
<template>
    <form>
        <input name="name" v-model="name">
        <input name="email" v-model="email">
        <input name="street" v-model="street">
        <input name="city" v-model="city">
        <input name="state" v-model="state">
        <input name="zip" v-model="zip">
    </form>
</template>
<script>
    export default {
        data() {
            return {
                name: '',
                email: '',
                street: '',
                city: '',
                state: '',
                zip: ''
            }
        }
    }
</script>

One of the first things I like to do with a form like this is to break apart data into reusable chunks. Let’s start by pulling out the mailing address.

Grouping Like Data

Notice how there are several inputs that make up an address? Let’s modify our form to group those inputs.

// Form.vue
<template>
    <form>
        <input name="name" v-model="name">
        <input name="email" v-model="email">
        <input name="street" v-model="address.street">
        <input name="city" v-model="address.city">
        <input name="state" v-model="address.state" />
        <input name="zip" v-model="address.zip">
    </form>
</template>
<script>
    export default {
        data() {
            return {
                name: '',
                email: '',
                address: {
                    street: '',
                    city: '',
                    state: '',
                    zip: ''
                },
            }
        }
    }
</script>

That’s better, but I think we should also break the form into components that represent the chunks of data. It seems logical to wrap all of the address-related inputs into a single Address component and pass the relevant information as props:

// Address.vue
<template>
    <div>
        <input name="street" v-model="street">
        <input name="city" v-model="city">
        <input name="state" v-model="state">
        <input name="zip" v-model="zip">
    </div>
</template>
<script>
    export default {
        props: ['street', 'city', 'state', 'zip']
    }
</script>

Now that we’ve grouped the address fields into a single Address component that accepts props for each of the inputs, our original form can be modified like this:

// Form.vue
<template>
    <form>
        <input name="name" v-model="name">
        <input name="email" v-model="email">
        <mailing-address
            :street="address.street"
            :city="address.city"
            :state="address.state"
            :zip="address.zip"
        />
    </form>
</template>
<script>
    import MailingAddress from './Address.vue';
    export default {
        components: { MailingAddress },
        data() {
            return {
                name: '',
                email: '',
                address: {
                    street: '',
                    city: '',
                    state: '',
                    zip: ''
                }
            }
        }
    }
</script>

Unfortunately, if we run this and edit any address fields we will get a console warning informing us that we should not manipulate props!

Avoid mutating a prop directly since the value will be overwritten whenever the parent component re-renders. Instead, use a data or computed property based on the prop's value. Prop being mutated: "street"

Introducing a Controlled Component

Fortunately there is a solution! One of the ways around this issue is to change the Address component to a "controlled" component using v-model.

// Form.vue
<template>
    <form>
        <input name="name" v-model="name">
        <input name="email" v-model="email">
        <mailing-address v-model="address" />
    </form>
</template>
<script>
    import MailingAddress from './Address.vue';
    export default {
        components: { MailingAddress },
        data() {
            return {
                name: '',
                email: '',
                address: {
                    street: '',
                    city: '',
                    state: '',
                    zip: ''
                }
            }
        }
    }
</script>

What is v-model?

v-model is nothing more than a shortcut for two things: 1) passing a valueprop to a component and 2) listening for an input event. Take a look at this updated Form component example:

// Form.vue
<template>
    <form>
        <input name="name" v-model="name">
        <input name="email" v-model="email">
        <mailing-address
            :value="address"
            @input="(newAddress) => {address = newAddress}"
        />
    </form>
</template>
<script>
    import MailingAddress from './Address.vue';
    export default {
        components: { MailingAddress },
        data() {
            return {
                name: '',
                email: '',
                address: {
                    street: '',
                    city: '',
                    state: '',
                    zip: ''
                }
            }
        }
    }
</script>

See how we are passing a value prop to the Address component on line 7 and how we are listening for an input event on line 8, upon which we update the address property with the payload newAddress?

As it turns out, v-model is a handy helper for consolidating these two items into a single, convenient component parameter.

Updating the Address Component

Once we’ve updated the Form component we need to make a few changes to the Address component in order to properly respond to v-model.

// Address.vue
<template>
    <div>
        <input name="street" v-model="value.street">
        <input name="city" v-model="value.city">
        <input name="state" v-model="value.state">
        <input name="zip" v-model="value.zip">
    </div>
</template>
<script>
    export default {
        props: {
            value: {
                type: Object,
                required: true
            }
        },
        watch: {
            value() {
                this.$emit('input', this.value);
            }
        }
    }
</script>

As we saw above, v-model will pass in a prop named value by default and will listen for an input event.

Now in the Address component we accept the value prop and emit the input event when changes occur.

Note: In certain situations you might notice that parent data is updated without any input events being emitted from the child. In these cases, the value prop is a deep object, and the changes are automatically reflected in the parent component. When passing a primitive value, however, we would need to emit the input event from the child to update data on the parent.

Nesting Controlled Components

This method of controlling components works great for grouping data into child components. Imagine we want to use a controlled select dropdown for the state value instead of requiring the user to type in their state.

Let’s try using the same v-model/value tactic from above. After adjusting our Address component we now have:

// Address.vue
<template>
    <div>
        <input name="street" v-model="value.street">
        <input name="city" v-model="value.city">
        <state v-model="value.state" />
        <input name="zip" v-model="value.zip">
    </div>
</template>
<script>
    import State from "./State";
    export default {
        components: { State },
        props: {
            value: {
                type: Object,
                required: true
            }
        },
        watch: {
            value() {
                this.$emit('input', this.value);
            }
        }
    }
</script>

And in the corresponding State component:

// State.vue
<template>
    <select v-model="value">
        <option v-for="(state, abbreviation) in states"
                :value="abbreviation"
                v-html="state"
        ></option>
    </select>
</template>
<script type="text/babel">
    export default {
        props: {
            value: {
                type: String,
                required: true
            }
        },
        data() {
            return {
                states: {
                    NY: 'New York',
                    WI: 'Wisconsin'
                    // + rest of the states
                }
            }
        }
    }
</script>

Notice how we have the double-nested v-model? The first v-model is from the Form component to the Address component. The second is the v-modelfrom the Address component into the State component.

If we run this we will end up with the familiar “Avoid mutating a prop…” console error.

So we need to make a few modifications to our components to conveniently chain v-model through nested components:

// Address.vue
<template>
    <div>
        <input name="street" v-model="localAddress.street">
        <input name="city" v-model="localAddress.city">
        <state v-model="localAddress.state" />
        <input name="zip" v-model="localAddress.zip">
    </div>
</template>
<script>
    import State from "./State";
    export default {
        props: {
            value: {
                type: Object,
                required: true
            }
        },
        components: { State },
        computed: {
            localAddress: {
                get() { return this.value },
                set(localAddress) {this.$emit('input', localAddress)}
            }
        }
    }
</script>

Breaking it Down

Let’s talk through these changes to clarify what’s going on.

props: {
    value: {
        type: Object,
        required: true
    }
},

The incoming data from v-model will default to a prop named value, so we are accepting it here.

computed: {
    localAddress: {
        get() { return this.value },
        set(localAddress) {this.$emit('input', localAddress)}
    }
}

Inside the Address.vue component we are using Vue's computed setter, which allows us to both receive a dynamic value as well as call a function when we attempt to change that value.

A computed setter allows us to nest v-model calls and automatically keeps the data synced between parent and child components. Pretty cool!

So now we can update our State component like this:

// State.vue
<template>
    <select v-model="localState">
        <option v-for="(state, abbreviation) in states"
                :value="abbreviation"
                v-html="state"
        ></option>
    </select>
</template>
<script type="text/babel">
    export default {
        props: {
            value: {
                type: String,
                required: true
            }
        },
        data() {
            return {
                states: {
                    NY: 'New York',
                    WI: 'Wisconsin'
                    // + rest of the states
                }
            }
        },
        computed: {
            localState: {
                get() {return this.value},
                set(localState) { this.$emit('input', localState)}
            }
        }
    }
</script>

In the same way we used a computed setter in the Address component, we are able to use one in the State component! Then, whenever the localStatevalue changes, it emits the input event back to the Address component, which in turn emits to the Form component.

How cool is that???

Conclusion

It took me a fair bit of time to wrap my mind around how data flows usingv-model. Understanding that v-model is nothing more than syntactic sugar for passing a prop to a child and emitting an event from the child when the data changes has helped greatly.

Hopefully this helps you conquer your nested controlled components in the future!

  1. Cover image