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.