Vue.js Headless Component
In the previous article we looked into scoped slots which we will now explore further by introducing the concept of "headless" or how they are sometimes called "renderless" components.
Headless components aim for maximum flexibility by completely separating the logic from the rendering. This is especially useful when a component contains a large amount of business logic.
Let's look into a typical example made famous by Kent Dodds when he introduced these concepts more deeply in the context of React where render props are used for similar use cases.
The Toggle Component #
The Toggle
component encapsulates logic to toggle a Boolean
state useful for various kinds of scenarios including switch components, expand/collapse scenarios, accordions, etc.
Sometimes it helps to figure out the component requirements when fleshing out first how the component will be used:
<Toggle @change="handleChange">
<template v-slot:default="{active, toggle}">
<button @click="toggle" class="button">Toggle</button>
<div></div>
</template>
</Toggle>
We start with a button which toggles the active
state. The active
and toggle
props are passed along via a scoped slot as seen already in the previous chapter. The change
event is useful to users of the Toggle
component to get notified of changes.
The template of our Toggle
only really needs to use the slot
mechanism to pass these props along:
<template id="toggle-template">
<slot :active="active" :toggle="toggle"></slot>
</template>
And the Toggle
component itself defines the active
state and the toggle
method which is responsible for toggling the state and emitting the change
event.
Vue.component("Toggle", {
template: "#toggle-template",
data() {
return {
active: false
}
},
methods: {
toggle() {
this.active = !this.active;
this.$emit("change", this.active);
}
}
});
And the Vue instance implements the handleChange
method:
new Vue({
el: '#demo',
methods: {
handleChange(active) {
console.log("changed to ", active)
}
}
});
You can find the complete example on GitHub
The example by itself is not really showing the flexibility of the headless component pattern. But, it exemplifies the complete separation of state management logic and the actual rendering. The latter is completely up to the client to implement.
Reusing the component together with a Switch Component #
Let's implement another example but this time with a more complex component: the switch component.
<Toggle @change="handleChange">
<template v-slot:default="{active, toggle}">
<switch-toggle :value="active" @input="toggle"></switch-toggle>
<div></div>
</div>
</Toggle>
Note, how the usage did not change at all. The only difference is that instead of a button we have a switch toggle.
The switch component's implementation is not important for this example, but let's go over it quickly. First of all: It is a controlled component and has no internal state.
Vue.component("SwitchToggle", {
template: "#switch-template",
props: {
value: {
type: Boolean,
default: false
}
}
});
And the template:
<template id="switch-template">
<label class="switch">
<input type="checkbox" :checked="value" @change="$emit('input', $event.target.checked)"/>
<div class="switch-knob"></div>
</label>
</template>
The value
prop is bound to the checked
attribute and on change we emit an input
event with the current state.
Isn't it fantastic that we could reuse our Toggle
component unchanged here even though the end result looks completely different?
There's one more thing! Since the Toggle
component does not really render much besides the slot, we can simplify our code but using a render function instead of a template:
Vue.component("Toggle", {
template: "#toggle-template",
render() {
return this.$scopedSlots.default({
active: this.active,
toggle: this.toggle
})[0];
},
data() {
return {
active: false
}
},
methods: {
toggle() {
this.active = !this.active;
this.$emit("change", this.active);
}
}
});
You can find the complete example on GitHub
The component is now solely defined via JavaScript containing the business logic. No template used at all. Nice!
You can read up some more details in the Vue.js Guide.
Let's see how far we can go with our Toggle
component and if we can make it even more flexible.
Expand/Collapse Component and Prop Collections #
Our Toggle
can be reused again for a completely different use case. We want to implement a simple expand/collapse toggle which looks like this.
And we can achieve it by using markup only:
<Toggle @change="handleChange">
<template v-slot:default="{active, toggle}">
<div class="expandable">
<h2 class="expandable__header">
Heading 2
<button class="expandable__trigger" @click="toggle" aria-expanded="active">
<svg aria-hidden="true" focusable="false" viewBox="0 0 10 10">
<rect v-if="active" height="8" width="2" y="1" x="4"/>
<rect height="2" width="8" y="4" x="1"/>
</svg>
</button>
</h2>
<div v-if="active" class="expandable__content">
Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, ...
</div>
</div>
</div>
</Toggle>
You can find the complete example on GitHub
There is a lot going on here. So, let us break it down!
We define a header element which contains a button to toggle the state using the toggle
prop. The active
prop is used to conditionally render a div
containing the expandable content.
Additionally, the active
prop is used again to render a slightly different SVG icon depending on if the state is expanded or collapsed:
<svg aria-hidden="true" focusable="false" viewBox="0 0 10 10">
<rect v-if="active" height="8" width="2" y="1" x="4"/>
<rect height="2" width="8" y="4" x="1"/>
</svg>
Note, how the active
prop is used with the v-if
directive? This will either hide or show the vertical rectangle, which means the +
icon is turned into a -
icon.
You might have noticed the use of the aria attributes on the button and on the SVG icon. These are specifically used to support screen readers. The blog article Collapsible Sections by Heydon Pickering is an excellent introduction to using aria attributes and the example code in the blog article is the basis of the component you see here.
There's an opportunity here to generalize the Toggle
component even more. We could always support the toggling action by providing a click
event instead of a toggle
. And the aria-expanded
attribute could be somehow passed along, too.
Let's first check how the usage would look like after making these props available:
<Toggle @change="handleChange">
<template v-slot:default="{active, togglerProps, togglerEvents}">
<div class="expandable">
<h2 class="expandable__header">
Heading 2
<button class="expandable__trigger" v-bind="togglerProps" v-on="togglerEvents" >
<svg aria-hidden="true" focusable="false" viewBox="0 0 10 10">
<rect v-if="active" height="8" width="2" y="1" x="4"/>
<rect height="2" width="8" y="4" x="1"/>
</svg>
</button>
</h2>
<div v-if="active" class="expandable__content">
Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, ...
</div>
</div>
</div>
</Toggle>
The scoped slot now provides active
, togglerProps
and togglerEvents
and the toggle
is gone. The togglerProps
is actually not a single prop but an object with multiple props. Is is therefore convenient to use v-bind
to apply all props automatically. Same goes for the togglerEvents
where we have to use v-on
instead, since these are events.
The implementation of Toggle
component slightly changes to pass along these new props:
Vue.component("Toggle", {
render() {
return this.$scopedSlots.default({
active: this.active,
toggle: this.toggle
togglerProps: {
'aria-expanded': this.active
},
togglerEvents: {
'click': this.toggle
}
})[0];
},
data() {
return {
active: false
}
},
methods: {
toggle() {
this.active = !this.active;
this.$emit("change", this.active);
}
}
});
You can find the complete example on GitHub
The scoped slot passes along the togglerProps
with the aria-expanded
attribute and the togglerEvents
with the click
event to toggle the state.
We achieved not only an increased reusability but additionally made it more user-friendly by managing the aria-expanded
attribute automatically.
Summary #
In this article we looked into Headless or Renderless components using Vue.js scoped lots and showed how to create highly reusable components which focus only on the logic and leave the rendering to the client.
It is fascinating that the Vue.js slot mechanism can be used for such a large variety of use cases. And it will be interesting to watch the community come up with even more ideas.
If you liked this article you can find much more content in my Vue.js Component Patterns Book. Its free :-)