Using v-model on Nested Vue Components
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 value
prop 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-model
from 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!
Update: It has been correctly noted that the computed setter will not work on an object like the example above indicates. This is because Vue only tracks changes to parent-level properties, and as a result, changes to values within the localAddress will not trigger the setter method. Instead, we can watch the localAddress computed object and emit the input event like this:
watch: {
localAddress: {
handler(newVal) {
this.$emit('input', newVal)
},
deep: true
}
},
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 localState
value 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!
Want to read more tips and insights on working with a Vue development team that wants to help your organization grow for good? Sign up for our bimonthly newsletter.
By Jesse Schutt
Director of Engineering
Jesse is our resident woodworker. His signature is to find the deeper meaning in a project and the right tool for the job.