Pass props to dynamic Vue components
While brainstorming some new Vue components (that sounds familiar), I thought it would be nice to loop over a list of items and dynamically render the correct component for each one. The problem is that the components being rendered do not take the same props.
Disclaimer: this article is a bit more complicated than my last one. I assume that you understand the basics of Vue components and that you have some knowledge of modern JS patterns such as Array.map
.
In Vue, it's very common to loop over a list of items and render a component for each item. This is usually done by specifying the component that will be rendered and adding a v-for
to its tag in the template.
<!-- Contacts.vue -->
<template>
<contact-card
v-for="person in people"
:key="person.id"
:contact="person" />
</template>
<script>
import ContactCard from 'ContactCard.vue'
export default {
components: {
ContactCard,
},
props: {
people: {
type: Array,
default: () => [],
}
}
}
</script>
This is a very straightforward pattern seen often in Vue apps. In the example above, we have a component ContactCard
that is meant to display a person's contact information. Let's take a closer look at ContactCard.vue
to get a feel for what's going on.
<!-- ContactCard.vue -->
<template>
<article>
<h1>{{ contact.name }}</h1>
<ul>
<li>Phone: {{ contact.phone }}</li>
<li>Email: {{ contact.email }}</li>
</ul>
</article>
</template>
...
So as you can see, we have a very basic component for displaying details of a contact. The root level is an article tag (yay HTML5) with an h1 tag containing the person's name and an unordered list of some contact information. Nothing crazy, but there is a required prop called contact
.
Dynamic Components
As we build more of the app, we get the idea to add a timeline that shows the order that data was added to the system, which includes creating contacts. Because we already have such a robust component for displaying contact information, we decided to re-use it in this timeline view. Let's take a look at a new component for that.
<!-- Timeline.vue -->
<template>
<contact-card
v-for="item in history"
:key="item.id"
:contact="item" />
</template>
<script>
import { historyArray } from 'Data'
import ContactCard from 'ContactCard.vue'
export default {
components: {
ContactCard,
},
computed: {
history () {
return historyArray
},
},
}
</script>
At first glance, the Timeline
component probably looks great. But let's look at the structure of historyArray
:
[
{
id: 'contact-1',
isContactItem: true,
...
},
{
id: 'event-1',
isSystemEventItem: true,
...
},
{
id: 'contact-2',
isContactItem: true,
...
},
]
A closer look reveals that there are more than just "contacts" in this historical data. Indeed, we have contacts and system events to display. Luckily, someone has already created a SystemEvent
component so we don't have to. Much like our ContactCard
this component has a single required prop (event
in this case) and displays relevant information about that prop. So let's change the Timeline
component to dynamically decide which component to use for each item in the list.
<!-- Timeline.vue -->
<template>
<component
v-for="item in history"
:key="item.id"
:is="item.component"
:contact="item" />
</template>
<script>
import { historyArray } from 'Data'
import ContactCard from 'ContactCard.vue'
import SystemEvent from 'SystemEvent.vue'
export default {
components: {
ContactCard,
SystemEvent,
},
computed: {
history () {
return historyArray.map(historyItem => {
if (historyItem.isContactItem) {
// Return a new object that contains ALL keys
// from `historyItem` and adds a `component` key
return {
...historyItem,
component: ContactCard,
}
} else if (historyItem.isSystemEventItem) {
return {
...historyItem,
component: SystemEvent,
}
}
})
},
},
}
</script>
You can see that instead of specifying contact-card
in the template, we are now using a special tag called component
. Along with this, there is a new is
prop being passed in. Inside of the history
computed value, we are checking each item to see if it is a contact or system event item (using some special helper functions that we assume exist), and we add the key component
that contains the relevant component to render for that particular item. In the loop within the template, the is
prop gets bound to that component
key. The end result is that contact items cause a ContactCard
component to be rendered and system event items cause a SystemEvent
component to be rendered.
Note: you can read up on the component
tag in the Vue docs.
If you're paying close attention, you may notice a slight problem: the SystemEvent
component takes a prop called event
, but the template is currently passing it a prop called contact
. How can we get around that? Well one option is to pass both contact
and event
props to every component. This technically won't cause a problem, but it feels a bit messy. Regardless, let's see what that might look like.
<!-- Timeline.vue -->
<template>
<component
v-for="item in history"
:key="item.id"
:is="item.component"
:contact="item"
:event="item" />
</template>
...
Now every component in the list will be passed contact
and event
. They are both being passed the same variable, so the ContactCard
component will see the contact
prop and behave correctly, and the SystemEvent
component will see the event
prop and behave correctly. This will work fine, but as you can imagine could quickly get out of hand if we have components with numerous props needed. There has to be a better way...
Dynamic Props
Wait a minute! If we are dynamically declaring what component is going to be rendered, can't we dynamically declare what props that component should receive? If you read my last Vue post, then you already know that v-bind
allows you to bind an entire set of props in one go. So let's see if we can apply that here.
Note: You can read more about passing an object's properties with v-bind
in the Vue docs.
<!-- Timeline.vue -->
<template>
<component
v-for="item in history"
:key="item.id"
:is="item.component"
v-bind="item.props" />
</template>
<script>
import { historyArray } from 'Data'
import ContactCard from 'ContactCard.vue'
import SystemEvent from 'SystemEvent.vue'
export default {
components: {
ContactCard,
SystemEvent,
},
computed: {
history () {
return historyArray.map(historyItem => {
if (historyItem.isContactItem) {
// Return a new object that contains a `component`
// key, an `id` key, and a `props` object
return {
id: historyItem.id,
component: ContactCard,
props: {
contact: historyItem,
},
}
} else if (historyItem.isSystemEventItem) {
return {
id: historyItem.id,
component: ContactCard,
props: {
event: historyItem,
},
}
}
})
},
},
}
</script>
Alright, I know the function for our history
computed value is starting to get crazy, but it's really not doing much. If it's hard to follow, here is an example of what the resulting data structure would look like:
[
{
id: 'contact-1',
component: ContactCard,
props: {
contact: {...}
}
},
{
id: 'event-1',
component: SystemEvent,
props: {
event: {...}
}
},
{
id: 'contact-2',
component: ContactCard,
props: {
contact: {...}
}
}
]
Take another look at the template now that you have an idea of how history
is structured. Notice that the contact
and event
props were removed, and we just have a single v-bind
instead. The value we give v-bind
is item.props
, which according to the snippet above, will contain the prop that is appropriate for each component. This is much cleaner than our previous approach, and keeps the template easy to read. If the components differed more, the history
logic could easily be broken into multiple functions.
Summary
Sometimes there is a need to dynamically choose the component to display when looping over a list of items. This is very well supported by VueJS and made easy using the component
tag. As complexity grows and components begin to be re-used throughout an application, these components may have prop interfaces that aren't really compatible with each other. Dynamically binding props to the component, just like dynamically declaring the component to be used, helps keep the template clean and readable.