Building a Flexible Table Component with Vue JS

If you've known me or worked with me for any length of time, it's no secret that I'm a big fan of Vue.js. While I've had a little more experience now working with React, I still find myself coming back to Vue because of the robust 1st party ecosystem, plug-n-play modularity, and defacto organization of components. Single File Components (SFC) are amazing! But that's not what we're here to chat about today. Today we're talking about something no one likes; tables.

Note, this tutorial should be relevant to both Vue 2 and Vue 3. The syntax for slots was unified in the later years of Vue 2 to better match what was coming in v3.

If you've ever had to build a table component of any complexity with a JS framework, you know it can be very tricky to nail the abstraction. Do I need sorting? Should the rows be striped? How can I customize specific cells? What sort of things should I expose as props? These are all important questions that you'll eventually ask yourself while working on any project with a usage life of more than a few months.

Let me first start by saying this; if your app/site only needs 1 table with static data, don't make things complicated. Write it inline or maybe extract it to a new component just to not clutter up your page component with a lot of static data. I would even go so far as to say, if you need a lot of complicated features (like sorting) but it's all confined to this one table, to build it all in one file, or perhaps break it up for legibility. The logic though should remain in the main file however.

Where a reusable table component becomes useful, is when you actually need to reuse it! So often I'll see someone fretting over an abstraction and wasting time for something that doesn't matter. If it's only used once, it's not worth abstracting.

At work, we've began building out some new pages to go alongside a new redesign. As part of this effort, we're creating various components for bits of UI that are used throughout the new designs and layouts. Normal stuff, buttons, inputs, cards with shadows, etc. Two weeks ago, I was given free reign to build out a brand new feature within our app. This feature was largely build around a few very similarly styled tables, but each one featuring a small bit of custom functionality. In one table, the data was sortable, while in another, we needed checkboxes in each row to multi-select various rows and process them for some bulk action.

When I started the build, I did it all statically. I built the entire feature in the first week of our sprint with zero abstractions. I started with the simplest table, a few columns with static content and no special features. Then I moved on to a more complicated table, copy and pasting the first as the basis for my next iteration. By the time I got to table number 4, I was seeing some concrete patterns emerge about how the CSS needed to change to accommodate each of the unique workflows.

I feel like a broken record, but this is why I think it's so important to not abstract too early, even if you know that you're about to build 5 of the same thing. When you have a lot of duplicated examples to compare to each other, the real patterns stand out so clearly. I know from experience that attempting to discern the abstraction before you've even made a first pass rarely turns out well. A great abstraction will lend itself to a quick refactor, so don't be afraid to "waste time" earlier in the process, so you can fly across the finish line at the end!

Now, I'll be the first to admit that I also did some searching online to see how other people approach this. Unfortunately, a lot of articles around building tables with JS frameworks tend to focus on flashy, feature filled monstrosities that attempt to compete with the Bootstrap tables of yore. I didn't want that. Eventually, I stumbled across a Medium article outlining an interesting take on using dynamically named slots to expose various parts of the table to the consuming component.

Before I show you the code, let me try to explain. Imagine you have a table with a 3 columns, an ID, a name, and an email address. Since we're using a table, one fairly consistent feature is the stability of the visible columns. Imagine we assigned each of these columns a key, say id, name, and email. Now, for each row, we know we will need to populate each of those columns with some data. Normally, we do this by associating data from the row with a column. Here' what an example row might look like.

{
  id: 123,
  name: 'Mr. Potato Head',
  email: '[email protected]'
}

Makes sense right? We build out the table by first looping the rows, and then looping the columns, and for each column's key, display the appropriate data from the row. Standard stuff for building a table.

So let's take this one step further. When we loop over each column within a row, let's add a slot around the cell's content, using the column's key as the unique identifier.

<slot :name="`cell(name)`">
  <td>
    {{ row.name }}
  </td>
</slot>

I've simplified this quite a bit from the final form, but hopefully you can see where we're going! To override every name cell for our table component, we only need to provide a slot targeted at cell(name). We would do that using <template v-slot:cell(name)> or the shorthand <template #cell(name)>.

This simple abstraction allows us to target a certain bit of the table with extreme precision, using a very readable syntax. With scoped slot properties, we can even make more granular decisions within our slot template if one cell in particular needs even more special treatment. Pretty cool huh?

Now let's take it another step further. What if we added a slot for not only each cell, but also each row? Assuming our row has some sort of unique identifier, like an id or key property of it's own, we can also target those dynamically!

<slot :name="`row(${getKey(row)})`">
  <tr>
    <!-- the slots for cells are nested here within the row -->
  </tr>
</slot>

We're adding a little complexity here but it's still manageable. The name of the row slot is going to be equal to row(KEYOFTHEROWDATA). In my case, I'm using a helper function to try and guess a unique key, but you could also defer to something like an id property if that is always present on your row data.

What makes this cool, is that we can then override just one row as long as we know the unique identifier!

<template v-slot:row(12)>
  <tr>
    <td colspan="3">This is the row with an ID of 12!</td>
  </tr>
</template>

With this abstraction, you can now override just a single row within the table if necessary, without adding extra complexity for other rows that may not need to be rendered differently. Now let's go ahead and see the full example!

<script>
const BaseTable = {
    props: {
        fields: {
            type: Array,
            default: [],
            validator(fields) {
                return fields.find(field => field?.key === undefined || field?.label === undefined) === undefined
            },
        },
        rows: {
            type: Array,
            default: [],
        },
        emptyMessage: {
            type: String,
            default: 'No rows found.',
        },
        tableClass: {
            default: 'm-0 table-fixed overflow-hidden rounded-lg',
        },
        headClass: {
            default: 'bg-gray-200',
        },
        headRowClass: {
            default: 'uppercase',
        },
        headCellClass: {
            default: 'p-2 text-xs font-bold text-gray-800',
        },
        rowClass: {
            default: '',
        },
        cellClass: {
            default: 'p-2',
        },
    },
    methods: {
        getKey(row) {
            return (row?.key || row?.id) ?? JSON.stringify(row)
        },
    },
    computed: {
        visibleFieldKeys() {
            return Object.entries(this.fields).map(([_key, value]) => value.key)
        },
    },
}

export default BaseTable
</script>

<template>
    <table :class="tableClass">
        <slot name="thead" :css="headClass">
            <thead :class="headClass">
                <slot name="headRow" :css="headRowClass">
                    <tr :class="headRowClass">
                        <template v-for="field in fields">
                            <slot :name="`head(${field.key})`" :field="field" :value="field.label" :css="headCellClass">
                                <th :key="field.key" :class="field.class || headCellClass">
                                    {{ field.label }}
                                </th>
                            </slot>
                        </template>
                    </tr>
                </slot>
            </thead>
        </slot>
        <tbody v-if="rows.length">
            <template v-for="row in rows">
                <slot :name="`row(${getKey(row)})`" :row="row" :css="rowClass">
                    <tr :key="getKey(row)" :class="rowClass">
                        <template v-for="objectKey in visibleFieldKeys">
                            <slot :name="`cell(${objectKey})`" :value="row[objectKey]" :row="row" :css="cellClass">
                                <td :class="cellClass" :key="objectKey">
                                    {{ row[objectKey] }}
                                </td>
                            </slot>
                        </template>
                    </tr>
                </slot>
            </template>
        </tbody>
        <tbody v-else>
            <slot name="no-data-row">
                <tr :class="rowClass">
                    <slot name="no-data-cell">
                        <td :colspan="fields.length" :class="cellClass">{{ emptyMessage }}</td>
                    </slot>
                </tr>
            </slot>
        </tbody>
    </table>
</template>

Wen you combine all the potential options for where to insert a slot, this pattern becomes extremely flexible without creating a ton of overhead.

Say I wanted to make the table sortable by name. Since the table is driven by data loaded from somewhere else, we essentially only need to add a button to the top of the name column that can fire the method to sort our underlying table data. Let's see how we might implement that.

<BaseTable>
  <template #head(name)="{ css, value }">
    <th :class="[css]">
      <div class="flex items-center justify-between">
        <div>{{ value }}</div>
        <button aria-label="sort" @click="sortByName">πŸ‘‡οΈ</button>
      </div>
    </th>
  </template>
</BaseTable>

How easy was that?!?! We haven't had to dig into the table to hook in a bunch of events, we don't need a bunch of callbacks; everything we need is in scope the component controlling the table's data. The BaseTable component purely exists to display the data. Each slot also exposes the original CSS for the element so we can choose to maintain the same styling, or we're free to ignore it and roll our own for that cell/row. We also pass back props of the original value for the cell and optionally we can include the full row of data if we need that to make other decisions.

What's also great about this is that it's easy to extend into other abstractions if you find yourself using the same patterns over and over again. Your BaseTable component can stay very simple while more specialized components can emerge for often reused patterns, eg. SortableTable or SelectableTable.

Hopefully this gives you a good idea as to how you may create a table component for your project. If you happen to implement something similar to this in your project, I'd love to see it! Tag me in a tweet @daronspence showing off your work! I'm always really interested to see how folks are utilizing these very low level components within their applications. I can't wait to see your take on tables!

Posted in:

Web Development