loading...

Vue.js Headless Component

fdietz profile image Frederik Dietz ・6 min read

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>{{active ? "yes" : "no"}}</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>{{active ? "yes" : "no"}}</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.

Toogle Button


Example 1

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.

expand/collapse Button


Example 2

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 :-)

Discussion

markdown guide