Renderless Behavioral Slots in Vue.js

David Desmaisons

Let’s go over the renderless slot pattern in Vue and see the problems that it can help solve.

Introduced with Vue.js 2.3.0, scoped slots have considerably improved component reusability. For example, the renderless component pattern emerged and solved the problem of providing reusable behavior and pluggable presentation.

Here we’ll see how to solve the opposite problem: providing reusable presentation and pluggable behavior.

Renderless Components

This pattern applies for components that implement complex behavior and have customizable presentation.

To do so:

  1. The component implements all the behavior
  2. Scoped slots are responsible for the rendering
  3. Fallback content ensures that the component can be used out-of-the-box.

Let’s take an example: a component performing an Ajax request and having a slot to display the result. The component handles the Ajax request and the loading state while the default slot provides the presentation.

Here a simplified implementation:

<template>
  <div>
    <slot v-if="loading" name="loading">
        <div>Loading ...</div>
    </slot>
    <slot v-else v-bind={data}>
    </slot>
  </div>
</template>

<script>
export default {
  props: ["url"],
  data: () => ({
    loading: true,
    data: null
  }),
  async created() {
    this.data = await fetch(this.url);
    this.loading = false;
  }
};
</script>

Usage:

<lazy-loading url="https://server/api/data">
  <template #default="{ data }">
    <div>{{ data }}</div>
  </template>
</lazy-loading>

For the original post about this pattern, check here.

Recommended: Vue School's Vue Master class course

A different problem

What if the problem is the contrary: imagine the main feature of a component is its presentation. On the other hand, behaviors should be customizable.

Imagine you are creating a tree component based on SVG, like this one:

tree component

You want to provide the SVG display and behavior such as retracting node and text highlighting on click.

A problem arises when you decide to not hard-code these behaviors and let the user of the component free to override them.

A simple solution to expose these behaviors would be to add methods and events to the component.

You’ll end up with something like:

<script>
export default {
  mounted() {
    // pseudo code
    nodes.on('click',(node) => this.$emit('click', node));
  },
  methods: {
    expandNode(node) {
      //...
    },
    retractNode(node) {
      //...
    },
    highlightText(node) {
      //...
    },
  }
};
</script>

To add behavior to the component, the consumer of the component will need to use a ref in a parent component, something like:

<template>
  <tree ref="tree" @click="onClick"></tree>
</template>

<script>
export default {
  methods: {
    onClick(node) {
      this.$refs.tree.retractNode(node);
    }
  }
};
</script>

This approach has several drawbacks:

  1. It’s not possible to provide a default behavior anymore
  2. Behaviors end up as a cookbook that you need to duplicate
  3. Behaviors are not reusable

Let’s see how renderless slots can solve these issues.

Renderless Slots

A behavior consists basically of proving a reaction to an event. So let’s create a slot that receives access to events and component methods:

<template>
  <div>
    <slot name="behavior" :on="on" :actions="actions">
    </slot>
  </div>
</template>

<script>
export default {
  methods: {
    expandNode(node) { },
    retractNode(node) { },
   //...
  },
  computed:{
    actions() {
      const {expandNode, retractNode} = this;
      return {expandNode, retractNode};
    },
    on() {
      return this.$on.bind(this);
    }
  }
};
</script>

The on attribute is the $on method of the parent component, so it’s possible to listen to all events.

Implementing a behavior can be done as a renderless component. Let’s write the expand-on-click component:

export default {
  props: ['on','action']

  render: () => null,

  created() {
    this.on("click", (node) => {
      this.actions.expandNode(node);
    });
  }
};

Usage:

<tree>
  <template #behavior="{ on, actions }">
    <expand-on-click v-bind="{ on, actions }"/>
  </template>
</tree>

The main advantages of this solution are:

  • The possibility to provide a default behavior by providing a fallback content:

For example, by declaring the graph component as:

<template>
  <div>
    <slot name="behavior" :on="on" :actions="actions">
      <expand-on-click v-bind="{ on, actions }"/>
    </slot>
  </div>
</template>
  • The possibility to create a reusable component that implements standard behavior that the component’s user can cherry pick

Let’s consider a highlight-on-hover component:

export default {
  props: ['on','action']

  render: () => null,

  created() {
    this.on("hover", (node) => {
      this.actions.highlight(node);
    });
  }
};

Overriding standard behavior:

<tree>
  <template #behavior="{ on, actions }">
    <highlight-on-hover v-bind="{ on, actions }"/>
  </template>
</tree>
  • Behavior slot are composable

Let’s add two pre-defined behaviors:

<tree>
  <template #behavior="{ on, actions }">
    <expand-on-click v-bind="{ on, actions }"/>
    <highlight-on-hover v-bind="{ on, actions }"/>
  </template>
</tree>
  • Readability of the solution

Component as behavior are self-explanatory.

  • Extensibility

The on attribute gives access to all the component events. New events are by default available for the slot.

Wrapping Up

Renderless slots present an interesting alternative to expose method and events in a component. They provide more readable and reusable code.

The code of the tree component implementing this pattern is available on github: Vue.D3.tree

  Tweet It

🕵 Search Results

🔎 Searching...

Sponsored by #native_company# — Learn More
#native_title# #native_desc#
#native_cta#