Sometimes in life, you just want to wrap your component's slot items in separate elements. This happened to me last year when I had to implement a component which provides a horizontal navigation of cards or other items. The "slider group", as I christened it, should create a wrapping element for each child. In case you are wondering, it was an atheistic christening ceremony which involves a lot of cake and also a band. You can see the component in action in the screenshot below.
When I was solving this problem I had a hard time coming up with a solution I was happy with. One year later, I was thinking about this issue again and had new ideas. Now, I would like to share with you what I discovered. For this purpose, we assume that we need a component which creates an <ul>
and wraps each child in a <li>
. In other words, I want this:
<our-component>
<span>foo</span>
<span>bar</span>
</our-component>
to render this DOM:
<ul>
<li><span>foo</span></li>
<li><span>bar</span></li>
</ul>
Two-component solution
This is probably the most straightforward approach of them all. The idea is to have an outer component which takes care of rendering our <ul>
and an inner component for the <li>
elements.
<!-- outer-wrapper.vue -->
<template>
<ul>
<slot />
</ul>
</template>
<script>
export default {
name: 'OuterWrapper'
}
</script>
<!-- inner-wrapper.vue -->
<template>
<li>
<slot />
</li>
</template>
<script>
export default {
name: 'InnerWrapper'
}
</script>
We can then use this component as follows:
<outer-wrapper>
<inner-wrapper>
<span>Hallo</span>
</inner-wrapper>
<inner-wrapper>
<span>Welt</span>
</inner-wrapper>
</outer-wrapper>
This approach allows splitting up logic between the two components which might result in cleaner code in some situations. This structure makes a lot of sense if the inner-wrappers have state or should receive attributes such as classes, styles or aria-*
. It is not surprising this approach can be seen frequently in component libraries such as Vuetify. In my original problem, however, this flexibility was not needed. I thought having two components is a little bit of an overkill considering my simple slider group.
Dynamic named slots
The probably hackiest solution of them all is to dynamically create one named slot for each item. Our component has to know how many slots to generate and which names they should have. We can solve this in two different ways.
- Our component receives a list of the desired slot names.
- We tell the component how many slots we need and it creates the slot names by incrementing starting at 0.
<template>
<ul>
<li v-for="i in itemCount" :key="i - 1">
<slot :name="i - 1" />
</li>
</ul>
</template>
<script>
export default {
name: 'NamedSlots',
props: {
itemCount: {
type: Number,
require: true
}
}
}
</script>
We can then use this component as follows:
<named-slots :item-count="2">
<template #0>
<span>Hallo</span>
</template>
<template #1>
<span>Welt</span>
</template>
</named-slots>
If you create the children based on data you will probably end up using it like this:
<template v-for="(item, index) in listItems" v-slot:[index]>
<span :key="index"></span>
</template>
We are down to only one component―Yay 🎉! The question is: is this really worth it? One year ago, I agreed and settled on this approach. Today, I have to admit that the heavy use of templates and the additional prop is less than elegant. Similar to the two-component solution, we can have multiple root-elements in the inner wrapper. This flexibility was not necessary in my case and you might not need it as well. So, if you only want to wrap each child, the next two approaches might be best suited.
Render function
When the template seems to be too limiting, the additional flexibility of a render function might be the saving grace. Indeed, we can express exactly what we want with a render function. Below, you will see that I first create an array of <li>
-VNodes each containing one slot item. I then return the <ul>
-VNode containing the list items.
<script>
export default {
name: 'RenderFunction',
render (h) {
const listItems = this.$slots.default.map(slotItem =>
h('li', [slotItem])
)
return h('ul', listItems)
}
}
</script>
We can then use this component as follows:
<render-function>
<span>Hallo</span>
<span>Welt</span>
</render-function>
Isn't this super slick? As with the dynamic named slots component, we again have only one component in total. What is different now is that the usage of the component is more straight forward. Some might argue that this implementation makes it less obvious that each child is wrapped. I think this is a valid point indeed. You have to find out for yourself if this is a dealbreaker for you in your situation.
Functional VNode component
This solution results in the same clean interface as the component with the render function. However, we implement two components this time because we declare a functional component inside the components
option (the component also be declared outside, of course, and imported as you are used to). This functional component provides a render function which only returns the VNode it received. Thus, the component allows us to place a VNode in the template. We can use this by iterating the slot items since $slots.default
is an array of VNodes.
<template>
<ul>
<li v-for="(item, i) in $slots.default" :key="i">
<v-nodes :vnodes="item" />
</li>
</ul>
</template>
<script>
export default {
name: 'FunctionalComponent',
components: {
VNodes: {
functional: true,
render: (h, ctx) => ctx.props.vnodes
}
}
}
</script>
Wrapping it up
Although I like how clean the usage of the components with the render function or the functional component is, I am aware of the intransparent behaviour. The more I think about it, the more I favour the approach with the two components because it seems to be the easiest and most transparent solution.
The good thing about having alternatives is that you can choose which one fits best in your current situation. The bad thing is that you have to make a decision. My next step is to create a performance benchmark. If one or more solutions perform exceptionally bad we have a strong argument to throw in the discussion.