Vue.js Component Composition with Scoped Slots

16 February, 2020

In the previous post we looked into slots and named slots to compose our components and content in a very flexible way.
There's one catch though we have not discussed. The content we pass to our slot is in the context of the parent component and not the child component. This sounds quite abstract, let's build an example component and investigate the problem further!

List of Items Example #

Probably the most canonical example for this kind of scenario is a todo list which renders for each todo a checkbox with name.

Example 1
Example 1

<div id="demo">
<div class="list">
<div v-for="item in listItems" key="item.id" class="list-item">
<input type="checkbox" v-model="item.completed" class="list-item__checkbox" />
{{item.name}}
</div>
</div>
</div>
new Vue({
el: '#demo',
data: {
listItems: [
{id: 1, name: "item 1", completed: false},
{id: 2, name: "item 2", completed: false},
{id: 3, name: "item 3", completed: false}
]
}
});

In the next step we refactor this code into a reusable list component and our goal is to leave it up to the client of the component to decide what and how to render the list item.

Refactor to Reusable List component #

Let's start with the implementation of the List component:

Vue.component("List", {
template: "#template-list",
props: {
items: {
type: Array,
default: []
}
}
});

<template id="template-list">
<div class="list">
<div v-for="item in items" class="list-item">
<slot></slot>
</div>
</div>
</template>

Following our previous examples we use the default slot to render a list item.

And now make use of our new component:


<div id="demo">
<List :items="listItems">
<div class="list-item">
<input type="checkbox" v-model="item.completed" class="list-item__checkbox" />
<div class="list-item__title">{{item.name}}</div>
</div>
</List>
</div>

But, when trying this example we run into a Javascript error message:


ReferenceError: item is not defined

It seems we cannot access item from our slot content. In fact the content we passed runs in the context of the parent and not the child component List.

Let's verify this by printing the total number of items in our List component using the listItems data defined in our Vue instance.


<div id="demo">
<List :items="listItems">
<div class="list-item">
{{listItems}}
</div>
</List>
</div>

That works because we run in the context of the parent component which is in this example the Vue instance. But, how can we pass the item data from our child <List> to our slot? This is where "scoped slots" come to the rescue!

Our component must pass along item as a prop to the slot itself:


<template id="template-list">
<div class="list">
<div v-for="item in items" class="list-item">
<slot :item="item"></slot>
</div>
</div>
</template>

Note, that it is important to pass this with a binding :item instead of only item!

Okay let's try this again:


<div id="demo">
<List :items="listItems">
<div slot-scope="slotProps" class="list-item">
<input type="checkbox" v-model="slotProps.item.completed" class="list-item__checkbox" />
<div class="list-item__title">{{slotProps.item.name}}</div>
</div>
</List>
</div>

This time we use the slot-scope attribute and assign the name slotProps to it. Inside this scoped slot we can access all props passed along via this slotProps variable.

In Vue.js 2.5.0+, scope is no longer limited to the <template> element, but can instead be used on any element or component in the slot.

Extending the rendering of the list item #

Now that we know how to pass data along we are free to extend the list item with some new functionality without changing the List component. It would be awesome if we could remove a todo item!

First of all we define the Vue app with a method to remove a todo item:

new Vue({
el: '#demo',
data: {
listItems: [
{id: 1, name: "item 1", completed: false},
{id: 2, name: "item 2", completed: false},
{id: 3, name: "item 3", completed: false}
]
},
methods: {
remove(item) {
this.listItems.splice(this.listItems.indexOf(item), 1);
}
}
});

We use the Javascript splice function to remove the item using it's index from listItems.

Next, we use this method when rendering the list item:


<template slot-scope="slotProps" class="list-item">
<input type="checkbox" v-model="slotProps.item.completed" class="list-item__checkbox" />
<div class="list-item__title">{{slotProps.item.name}}</div>
<button @click="remove(slotProps.item)" class="list-item__remove">×</button>
</template>

We add a button with a click event which calls our previously defined remove function. That's it!

Using Destructuring for the slot-scope #

We can further simplify this template by using a modern Javascript trick on the slot-scope attribute.

Here's an example of using Javascript "destructuring" to access an attribute of an object:

const item = slotProps.item;
// same as
const { item } = slotProps;

Instead of using the value slotProps we can now access the item directly.

Let's use this in our template:


<template slot-scope="{item}" class="list-item">
<input type="checkbox" v-model="item.completed" class="list-item__checkbox" />
<div class="list-item__title">{{item.name}}</div>
<button @click="remove(item)" class="list-item__remove">×</button>
</template>

This is easier to read because we can directly use the item variable instead of always going via slotProps.item.

Summary #

In this chapter we used scoped slots to allow the parent to access data from the child. This gives us lots of new possibilities which weren't possible before. This feature is especially useful in scenarios where you want to leave the rendering of the slot content to the user of the component. In our case the list component is very reusable by decoupling the rendering of the list items.

You can find the complete examples on Github.

If you like this post also check out my new course Vue.js Component Patterns Course.

Stay tuned for my upcoming post about headless components!