Nico Prat

Nico Prat

Creating the dumbest table component for Vue

How dynamic slot names can make a simple component solve complex problems


Creating the dumbest Table component for Vue

At some point, almost every app needs to display tabular data. Using simple HTML <table> element works great at first, but as the instances count grows up, the need to maintain consistent style and behavior becomes a pain. Now, there are numerous options for state-of-the-art table component for Vue ; to name a few:

They all have a lot of features, but they also share a drawback: they are too smart! It often also means a big footprint in dependency weight and maintenance. What if we just need a handful set of features? How can we simply reuse components already existing in our codebase? Could we create our own table component without wasting days recreating the wheel?

Luckily for us, Vue has an advanced feature (at least we didn't even need it for the past few years) that makes it pretty easy: dynamic slot names (Vue Docs)

Keep it simple stupid#

So here's the simplest example of a slot:

<template>
  <div>
    <slot name="foo" />
  </div>
</template>

But we can make this name prop dynamic where getSlotName returns a string, like this:

<template>
  <div>
    <slot :name="getSlotName" />
  </div>
</template>

This way we can create a simple table component that exposes a slot with an unique name for every column:

<!-- Tabular.vue -->
<template>
  <table>
    <tbody>
      <tr v-for="(row, i) in rows" :key="`${row}-${i}`">
        <td v-for="(column, j) in columns" :key="`${column}-${i}`">
          <slot :name="`tbody-${column}`" :data="row" />
        </td>
      </tr>
    </tbody>
  </table>
</template>

<script setup>
const props = defineProps({
  columns: {
    type: Array,
    required: true,
  },
  rows: {
    type: Array,
    required: true,
  },
})
</script>

This is really simple to use. By the way, if the # shortcut syntax looks weird, we added the regular one in comments.

<template>
  <Tabular :columns="getColumns" :rows="getRows">
    <!-- default tbody slot for all cells -->
    <!-- the slot syntax could also be :slot="`tbody-${column}`" -->
    <template v-for="column in getColumns" #[`tbody-${column}`]="{ data: user }">
      {{ user[column] }}
    </template>

    <!-- override one tbody slot for a specific column -->
    <!-- the slot syntax could also be slot="tbody-email" -->
    <template #tbody-email="{ data: user }">
      <a :href="`mailto:${user.email}`">{{ user.email }}</a>
    </template>
  </Tabular>
</template>

<script setup>
const getColumns = ref(['firstName', 'lastName', 'email'])
const getRows = ref([{ firstName: 'John', lastName: 'Doe', email: 'jonh@doe.com' }])
</script>

As you can see, the slot mechanism allows us to define a default behavior and override some specific slots only. In the example above, the tbody-email is actually defined twice, but only the later will be displayed. It keeps things legible and flexible, because everything happens right in the template.

Keep it flexible too#

Let's see how we can use the slots to make our example a bit more complex. As we have access to every row of data in the slot, we can reuse it as we need and import other components (avatar), and even create "fake" columns (fullName). Also, let's say we create slots for the thead section with the same mechanism. Finally, as the columns can actually contain anything (with minor adjustments in Tabular), we can easily add features like sorting in columns definition. It would look like this:

<template>
  <Tabular :columns="getColumns" :rows="getRows">
    <template v-for="({ sortable }, column) in getColumns" #[`thead-${column}`]>
      <SortingButton v-if="sortable" />
      <span>{{ column }}</span>
    </template>

    <template #tbody-avatar="{ data: user }">
      <!-- let's say we have an Avatar component taking a `picture` prop -->
      <Avatar :picture="`https://www.gravatar.com/avatar/${md5(user.email)}`"  />
    </template>

    <template #tbody-fullName="{ data: user }">
      {{ user.firstName }} {{ user.lastName }}
    </template>

    <template #tbody-email="{ data: user }">
      <a :href="`mailto:${user.email}`">{{ user.email }}</a>
    </template>
  </Tabular>
</template>

<script setup>
import md5 from 'md5';
const getColumns = ref({
  avatar: { sortable: false },
  fullName: { sortable: true },
  email: { sortable: false },
})
const getRows = ref([{ firstName: 'John', lastName: 'Doe', email: 'jonh@doe.com' }])
</script>

Push it further#

Now, every time a new instance needs a new feature, all previous ones can benefit from it. At some point, we had to stick the first row and column. It's as easy as adding some props and styles (yes position: sticky works in tables now!):

<template>
  <table :class="{ 'sticky-first-row': stickyFirstRow, 'sticky-first-column': stickyFirstColumn, }">
    <thead>
      <tr v-for="(row, i) in rows" :key="`${row}-${i}`">
        <td v-for="(column, j) in columns" :key="`${column}-${i}`">
          <slot :name="`thead-${column}`" :data="row" />
        </td>
      </tr>
    </tbody>
    <tbody>
      <tr v-for="(row, i) in rows" :key="`${row}-${i}`">
        <td v-for="(column, j) in columns" :key="`${column}-${i}`">
          <slot :name="`tbody-${column}`" :data="row" />
        </td>
      </tr>
    </tbody>
  </table>
</template>

<script setup>
const props = defineProps({
  columns: {
    type: Array,
    required: true,
  },
  rows: {
    type: Array,
    required: true,
  },
  stickyFirstRow: {
    type: Boolean,
    default: false,
  },
  stickyFirstColumn: {
    type: Boolean,
    default: false,
  },
})
</script>

<style scoped>
.sticky-first-row thead th {
  position: sticky;
  top: 0;
}

.sticky-first-column thead th:first-child,
.sticky-first-column tbody td:first-child {
  position: sticky;
  left:0
}
</style>

For our use cases, we also improved displaying big amount of data by adding a full screen mode and enhancements to scrolling thanks to the overlayscrollbars package. These are a few examples, but the possibilities are limitless!

Conclusion#

Simple features like dynamic slot can open whole new opportunities and solve complex problems with high flexibility. We use this component in a lot of pages and are happy to keep consistent styles and behaviors across the app with a very limited maintenance effort.

The only culprit I have found is the loss of TypeScript types when using the slot data, but it might be possible to solve this issue.

The next big step for us is to create a design system for this kind of "dumb" components, where developers can find documentation, designers can inspect styles, and maintainers can run tests.