A Practical Use-Case of Render Functions in Vue

Rendering Tabular Data With Dynamic Logic

A Practical Use-Case of Render Functions in Vue

What Are Render Functions?

Render functions are what Vue uses under the hood to render to the DOM. All vue templates are compiled into render functions which return a virtual DOM tree and get mounted to the actual DOM.

This template: 

<div>hello</div>

is compiled to

h(‘div’, ‘hello’)

Vue gives us the option to skip writing templates and instead directly author these render functions. Templates are so much easier to read and write, so why and when would you ever want to use render functions? That’s something I’ve always wondered about until something came up on a project I was working on a few months ago.

Background

The project I’m working on is pretty heavy on tables. We have multiple tables which display different data so naturally we built a component that takes data and displays it in an HTML table.

Our primary table started out simple but grew in complexity. Instead of displaying text in each cell, some cells needed to render out links, icons, buttons, tooltips, and other custom components. We did this by building new components for each of these new types of cells, but all of these new components became difficult to maintain.

In addition to this added complexity, our table grew in size. We started out with less than 15 columns but it ballooned to more than 50. Even after implementing virtual scrolling, with all 50 columns, the scrolling performance on the table was poor, especially on our client’s work machines. This performance drop was because 50+ component instances for each row needed to be mounted as they scrolled into view.

It seemed like the clear answer to both of these problems was to reduce the number of components being used, but how? One solution that we turned to was using slots and render functions.

Project Overview

I’ve built a starter project that takes user data and displays it in an HTML table. You can check it out on Stack Blitz.

Table of user data showing user's names, emails, phone numbers, and saved status

The BaseTable component takes two props: source which is the data that we’ll want to display, and columns which is an array of column definitions.


<template>
  <table>
    <thead>
      <tr>
        <th v-for="column in columns" :key="column.sourceKey">
          <HeaderCell :title="column.title" />
        </th>
      </tr>
    </thead>
    <tbody>
      <tr v-for="row in source" :key="row.id">
        <td v-for="column in columns" :key="column.sourceKey">
          <component
            :is="column.component"
            v-bind="column.props(row[column.sourceKey])"
          ></component>
        </td>
      </tr>
    </tbody>
  </table>
</template>

<script setup>
import HeaderCell from './HeaderCell.vue';

defineProps({
  source: {
    type: Array,
    required: true,
  },
  columns: {
    type: Array,
    required: true,
  },
});
</script>

This is what our user object looks like:


{
  id: 1,
  name: 'Lauri Pitman',
  email: 'lpitman0@google.com',
  phoneNumber: '568-246-1591',
  ip_address: '250.99.76.244',
  saved: false,
  avatar: 'https://robohash.org/nequenonfacere.png?size=50x50&set=set1',
  rating: 2,
},

And a column definition:


{
  sourceKey: 'name',
  title: 'Name',
  component: BaseCell,
  props(value) {
    return {
      sourceValue: value,
    };
  },
},

This object indicates what attribute from our data objects to use for each of the columns. It also provides a title which BaseTable uses as the column header, what component to use for the column cell, and a props object that is bound to the indicated component.

Our BaseCell component is pretty simple. It takes a single prop sourceValue and renders it directly:


<template>
  <div>
    {{ sourceValue }}
  </div>
</template>

<script setup>
defineProps({
  sourceValue: {
    type: [Array, String, Object, Number, Boolean],
    default: null,
  },
});
</script>

There are two other components being used in our table: LinkCell and IconCell which are being used for the ‘email’ and ‘saved’ columns respectively. LinkCell displays an email-to link and IconCell displays an svg icon that we import as a component with the help of vite-svg-loader. We’re going to update these columns to use BaseCell with the help of slots and render functions.

Implementing Render Functions

We want to use a render function to generate HTML and insert it into a slot in BaseCell.

We’ll start by adding a slot to BaseCell and keeping sourceValue as the default slot content. Now that we have a slot, we’ll want to add a prop called slotContent that takes a render function. We’ll also invoke it and save the return value into a variable called SlotContent. Since render functions return a virtual DOM node, we can insert this into a slot like any other component or tag.

BaseCell should look like this:


<template>
  <slot>
    <SlotContent v-if="slotContent" />
    {{ sourceValue }}
  </slot>
</template>

<script setup>
const props = defineProps({
  sourceValue: {
    type: [Array, String, Object, Number, Boolean],
    default: null,
  },
  slotContent: {
    type: Function,
    default: null,
  },
});
const SlotContent = props.slotContent && props.slotContent();
</script>

Now that BaseCell is ready to work with render functions, we can use it in our other columns.

Basic Usage

We’ll start with the email column. This column uses LinkCell which renders a simple mailto link.

Instead of using LinkCell in the email column, we can now try using BaseCell. In our props object, instead of sending in a sourceValue prop, we send in a render function in our slotContent prop which renders a link:


props(value) {
  return {
    slotContent: () => h('a', { href: `mailto:${value}` }, value),
  };
},

The first argument to this indicates the root tag to use which is an anchor tag. The second optional argument is an object where you can define props or attributes. In this case we want to set an href with a mailto link. The third argument sets the node’s children, which in this case is a string.

Conditional Rendering

Next we’ll convert the ‘saved’ column. In the ‘saved’ column definition, instead of using IconCell we can change it to use BaseCell and add another render function:


return {
  slotContent: () =>
    h('div', [
      value
        ? [
            h('span', { class: 'visually-hidden' }, 'yes'),
            h(BookmarkAddedIcon, {
              height: '24',
              width: '24',
              'aria-hidden': true,
            }),
          ]
        : h('span', { class: 'visually-hidden' }, 'no'),
    ]),
};

There is a bit more going on here than last time. First is the use of a ternary operator. This operates like v-if which we’re using to display a bookmark icon only if the value of our column is true. In the case that it is true, we also have multiple children under the root node: a bookmark icon and screen-reader only text. We’re also setting various attributes: a visually-hidden class for hiding the screen reader text, width, height, and an aria-hidden attribute for the svg.

Previously in our IconCell component, all of this was baked in. If we wanted to make any of this dynamic or if it needed to be used differently, we’d need to add more props and more functionality making it harder to maintain.

Using a render function this way helps separate the implementation of our components from our actual component code, making them simpler to maintain.

Loops and Slots

Next, we’re going to add a new column to our table – a ratings column. Each of our users has a rating 1-5 and instead of displaying the numeric value, we want to display the equivalent number of stars. We’ll start with a basic column definition:


{
  sourceKey: 'rating',
  title: 'Rating',
  component: BaseCell,
  props(value) {
    return {}
  }
},

We can render loops by using the map function:


slotContent: () =>
          h('div', [
            h('span', { class: 'visually-hidden' }, value),
            [...Array(value).keys()].map((star) => {
              return h(StarIcon, {
                key: star,
                height: '24',
                width: '24',
                'aria-hidden': true,
              });
            }),
          ]),

Here, we’re simply creating an array with the same length as our user’s rating and iterating through that to render the equivalent number of stars. Now we should have a new ratings column that displays a star rating for each user.

What if we want to see an average rating that gets displayed in the column header as a tooltip?
We can start by setting up HeaderCell similar to BaseCell resulting in this:


<template>
  <div>
    <span>
      {{ title }}
    </span>
    <slot>
      <SlotContent v-if="slotContent" />
    </slot>
  </div>
</template>

<script setup>
const props = defineProps({
  title: {
    type: String,
    default: '',
  },
  slotContent: {
    type: Function,
    default: null,
  },
});

const SlotContent = props.slotContent && props.slotContent();
</script>

We’ll also add a new key headerProps to our column definition so we can bind props to our component with v-bind:


          <HeaderCell
            v-bind="column.headerProps && column.headerProps(column)"
            :title="column.title"
          />

For our tooltip, we’ll install floating-vue which gives us a Tooltip component we can import in App.vue. This tooltip component contains two slots: a default slot that’s used for the trigger which we’ll put a button in, and a slot named popper that displays the tooltip content. In order to pass children into component slots, we’ll need to use slot functions.


headerProps() {
      return {
        slotContent: () =>
          h(Tooltip, null, {
            default: () =>
              h('button', [
                h('span', { class: 'visually-hidden' }, 'average rating'),
                h(InfoIcon, {
                  height: '18',
                  width: '18',
                  'aria-hidden': true,
                }),
              ]),
            popper: () =>
              h(
                'span',
                `Average: ${
                  source.reduce((acc, m) => acc + m.rating, 0) / source.length
                }`
              ),
          }),
      };
    },

Now we should have a button in the ratings header that opens up a tooltip that displays the average rating across our users. Without using render functions, we would have either needed to add that functionality to BaseCell or create another component that would just serve as a wrapper for Tooltip. Additionally, since Tooltip takes slots, we’d be constrained with what we can put in them.

This is how our completed table should look:
Table of user data showing user's names, emails, phone numbers, saved status, and rating

You can see the final result of this here.

Closing

Using render functions helps simplify our application code and adds more flexibility when rendering out data with dynamic logic. I hope this served as an informative introduction to render slots and how you can use them.

Loved the article? Hated it? Didn’t even read it?

We’d love to hear from you.

Reach Out

Leave a comment

Leave a Reply

Your email address will not be published. Required fields are marked *

More Insights

View All