Building a Reusable Table Component with Vue and TypeScript

Olunu AbiodunOlunu Abiodun
13 min read

At work, we decided to transition our admin dashboard from a third-party platform to a custom-built solution tailored to our needs. During the process of reviewing the features we needed to implement, we realized that a significant number of them involved tables.

While building a single table isn’t overly complex, creating and managing multiple tables can quickly become repetitive and time-consuming. Ensuring that <th> and <td> elements are perfectly aligned, and maintaining consistency across numerous tables requires careful attention. Additionally, tasks like moving a column can be cumbersome, as both the <th> and <td> elements must be updated in unison.

These challenges highlighted the need for a more efficient approach to table management. By building a reusable table component, we could encapsulate logic, improve consistency, and save time.

Let's get started on building! The first step was creating the AppTable.vue component in the components folder.

The AppTable.vue component is designed to be flexible and reusable, capable of rendering tables for any data structure. To achieve this, we use Vue’s Composition API with TypeScript, ensuring type safety and scalability. Here's how we began:

Setting Up the Script Block

<script 
  setup
  lang="ts"
  generic="T extends object, U extends Extract<keyof T, string>"
>

interface Props {
  tableData: T[];
  tableColumns: U[];
}

const props = defineProps<Props>()

</script>

This setup block uses TypeScript generics, a feature introduced in Vue components (documentation here), to make the table component adaptable to various data types. Generics allow us to define flexible yet type-safe components, ensuring that the table can work with any structure of data without compromising on type safety.

What’s Happening Here?

  1. generic="T extends object, U extends Extract<keyof T, string>"

    • T extends object: Constrains the type of each row in the tableData to be an object. This ensures the table works with structured data.

    • U extends Extract<keyof T, string>: Restricts the type of tableColumns to keys of T that are strings. This ensures that every column corresponds to a valid property in the data rows.

  2. Props Interface
    The Props interface defines the two main props for the table component:

    • tableData: An array of objects (T[]), representing the rows of the table.

    • tableColumns: An array of keys (U[]), representing the columns to be displayed.

Why Use Generics?

Using generics makes this table component adaptable to any data structure while ensuring type safety:

  • If the data rows (tableData) have properties like name or age, only those keys can be included in tableColumns.

  • This prevents errors such as referencing a column key that doesn’t exist in the data.

With this simple structure, you can already see how TypeScript starts to help. For example, let’s consider an array with the following structure:


const people: {
 name: string;
 age: number;
 occupation: string;
 salary: number;
 date_of_birth: string;
 email: string;
 phone: string;
 address: string;
 is_married: boolean;
 hobbies: string[];
}[]

Next, I'll import the AppTable.vue component into the App.vue file where I want to use it. I’ll pass the people array as the tableData prop. When defining the columns prop, TypeScript ensures that I can only select valid keys from the people array’s structure.

As you can see in the screenshot, the TypeScript integration provides a dropdown of all the valid keys for the columns prop, such as "name", "age", "address", and others. This constraint ensures that I can’t accidentally reference an invalid or non-existent property, making the component much more robust and reducing potential bugs.

Building the Table Component Template

Now let’s get back to the AppTable.vue component. In the template section, we’ll start by adding the basic structure of an HTML table:

<template>
  <table>
    <thead>
      <tr></tr>
    </thead>
    <tbody>
      <tr></tr>
    </tbody>
  </table>
</template>

This skeleton provides the foundation for rendering the table headers (<thead>) and the table rows (<tbody>). In the next steps, we’ll dynamically populate these sections using the tableData and tableColumns props passed to the component.

Adding Basic Styling

To make the table visually presentable, we’ll add some basic styling:

<style>
table {
  width: 100%;
  border: 1px solid white;
  border-collapse: collapse;
}

td,
th {
  border: 1px solid white;
}
</style>

This simple CSS ensures that the table spans the full width of its container, with clearly defined borders and a clean, minimalistic look.

Next, we’ll dynamically render the table headers. Inside the <thead> section, we’ll use the v-for directive to loop over the tableColumns prop. Each column key will be rendered as a header (<th>), like this:

<thead>
  <tr>
    <th v-for="col in tableColumns" :key="col">
      {{ col }}
    </th>
  </tr>
</thead>

What’s Happening Here?

  • v-for="col in tableColumns": This directive iterates through the tableColumns array, with each key being assigned to the variable col.

  • :key="col": The key attribute helps Vue efficiently track and update DOM elements when the data changes. Using the column key (col) ensures unique identifiers for each header.

  • {{ col }}: The column key is displayed as the header text.

This step dynamically generates headers based on the tableColumns array, ensuring flexibility and reducing the need for hardcoded <th> elements.

In the next step, we’ll apply a similar approach to populate the rows in the <tbody> section.

Dynamically Rendering Table Rows

To populate the table rows, we’ll use the tableData and tableColumns props in the <tbody> section. By iterating over these props, we can dynamically generate rows and their corresponding cells. Here’s how the implementation looks:

<tbody>
  <tr v-for="(data, i) in tableData" :key="i">
    <td v-for="(col, j) in tableColumns" :key="col + j">
      {{ data[col] }}
    </td>
  </tr>
</tbody>

What’s Happening Here?

  • v-for="(data, i) in tableData":
    This loop iterates through each object in the tableData array. Each object represents a single row in the table. The variable data holds the current row’s data, while i serves as its index for the key attribute.

  • v-for="(col, j) in tableColumns":
    For each row, this inner loop iterates through the keys defined in tableColumns. These keys determine which properties of the data object will be rendered in the table cells (<td>).

  • :key="col + j":
    A unique key is created for each cell by combining the column key (col) and its index (j). This ensures proper rendering and reactivity in Vue.

  • {{ data[col] }}:
    This accesses the value of the current column (col) in the current row (data) and displays it as the cell’s content.

The Result

With this setup, the table dynamically generates rows and fills each cell with the corresponding data, based on the tableData and tableColumns props. This approach ensures flexibility, as the table adapts to any data structure without needing additional hardcoding.

This is what the table looks like in the browser:

As shown above, the column header date_of_birth is rendered exactly as it appears in the data. However, this format isn’t user-friendly. To improve readability, we can create a simple function that splits strings by underscores and converts date_of_birth into date of birth.

Here’s the function:

function splitStringByUnderscore(str: string) {
  return str.split("_").join(" ");
}

Then, we can use this function in the <th> element to dynamically transform the column headers:

 <th v-for="col in tableColumns" :key="col">
    {{ splitStringByUnderscore(col) }}
 </th>

This ensures that headers are displayed in a more readable format, making the table easier to understand.

To make the table interactive, let’s add an event listener that emits a rowClick event whenever a user clicks on a row. This allows us to handle the clicked row data and use it for any custom functionality, like navigating to a detailed view or performing an action.

First, we define an interface for our event shape and register it using Vue's defineEmits:

// AppTable.vue

interface Emits {
  (event: "rowClick", value: T): void;
}

const emit = defineEmits<Emits>();

Next, we update the <tbody> section of our template to include the event listener for row clicks. Here's the modified template:

<!-- AppTable.vue  -->
 <tbody>
      <tr
        v-for="(data, i) in tableData"
        :key="i"
        @click="emit('rowClick', data)"
      >
        <td v-for="(col, j) in tableColumns" :key="col + j">
          {{ data[col] }}
        </td>
      </tr>
</tbody>

With this setup, clicking on a table row will emit a rowClick event, passing the corresponding row's data as a parameter. This makes the table versatile and allows parent components to handle the interaction however they need.

Here’s an example of how to handle the rowClick event in the parent component, App.vue. When a row is clicked, the data for that specific row is passed to the handleRowClick function, where it can be used as needed.

<script setup lang="ts">
import AppTable from "./components/AppTable.vue";
import { people } from "./people";

function handleRowClick(d) {
  console.log({ d });
}
</script>

<template>
  <AppTable
    :tableData="people"
    :tableColumns="[
      'name',
      'date_of_birth',
      'age',
      'email',
      'occupation',
      'phone',
    ]"
    @rowClick="handleRowClick"
  />
</template>

Explanation:

  1. Event Listener: The @rowClick="handleRowClick" binds the emitted event from AppTable to the handleRowClick function in App.vue.

  2. Row Data Handling: When a row is clicked, the data for that row is logged to the console in the handleRowClick function.

This setup demonstrates how you can connect the reusable table to the parent component and make it interactive with custom event handling.

We could stop here, as the table is already fully functional and dynamic. However, to make this component even more reusable and flexible, we’ll leverage Vue slots extensively. Slots allow us to customize specific parts of the table, such as the headers, rows, or even individual cells, enabling a more tailored rendering experience.

In the next steps, we’ll explore how to:

  1. Add header slots to allow custom rendering of table headers.

  2. Define cell slots for customizing cell layouts.

  3. Support scoped slots to access and manipulate row or column data dynamically.

This will take the table component from being just a dynamic table to being an ultra-flexible and reusable table component that can adapt to a wide variety of use cases.

The reason for introducing Vue slots is that sometimes we don’t want to render data exactly as it comes from the data source. Instead, we may want to display something more user-friendly or customized. For example, when a value is true, we might want to render "Yes" instead of true. Slots give us the flexibility to override and customize how data is displayed, making our table adaptable to various scenarios and design requirements.

Let’s begin by updating the <th> tag inside the <thead> section to the following. Here, we include fallback content splitStringByUnderscore(col) in case no custom content is provided for the slot:

 <!-- AppTable.vue  -->
<thead>
      <tr>
        <th v-for="col in tableColumns" :key="col">
          <slot :name="`header-${col}`">
            {{ splitStringByUnderscore(col) }}
          </slot>
        </th>
      </tr>
</thead>

We are dynamically generating the slot names because we don’t know the structure of the data source ahead of time. By leveraging the TypeScript setup introduced earlier, we can dynamically infer and suggest slot names based on the tableColumns provided. For example, if our data source includes a column named date_of_birth, but we want to display it as D.O.B for users (for any reason 😅), we can define a slot like header-date_of_birth and customize its content.

With this setup, TypeScript enhances the developer experience by suggesting valid slot names based on the structure of tableColumns, as shown in the screenshot below. This reduces the chances of errors and ensures the dynamic slots align perfectly with the data source.

As seen above, the combination of TypeScript and Vue provides a powerful developer experience. It not only helps with dynamic slot generation but also ensures type safety and autocompletion, making the development process more intuitive and error-free.

Now, let’s update the <td> elements within the <tbody>. We modify them as follows:

 <tbody>
      <tr
        v-for="(data, i) in tableData"
        :key="i"
        @click="emit('rowClick', data)"
      >
        <td v-for="(col, j) in tableColumns" :key="col + j">
          <slot :name="`cell-${col}`" :rowRecord="data" :value="data[col]">
            {{ data[col] }}
          </slot>
        </td>
      </tr>
</tbody>

Explanation:

This update mirrors the changes we made for the headers, but here we are leveraging scoped slots to allow the parent component to customize the rendering of each cell dynamically. Scoped slots enable the component to pass local data (like rowRecord and value) to the parent, giving the parent the flexibility to decide how to display the cell’s content.

"A scoped slot provides local data from the child component so that the parent component can choose how to render it."

In this case:

  • :rowRecord="data": Passes the entire row object (data) to the parent.

  • :value="data[col]": Passes the specific value of the current cell to the parent.

  • Fallback Content: If the parent does not provide a custom slot for the cell, the default content {{ data[col] }} is displayed.

This setup makes the table even more reusable, enabling highly customized cell content while maintaining a default fallback.

In App.vue, we are utilizing the cell- slots to customize how specific data is rendered in the table cells. For example:

  1. Formatting the Salary Column:

    • Our data source provides the salary as a number, but raw numbers aren’t always user-friendly. To make it easier to read, we format the salary using the Intl.NumberFormat API, which formats the number as currency. The value from the scoped slot is used directly to achieve this.
    <template #cell-salary="{ value }">
      {{ Intl.NumberFormat("en", { minimumFractionDigits: 2 }).format(value) }}
    </template>
  1. Customizing the is_married Column:

    • The is_married column is a boolean, but instead of showing true or false directly, we render "Yes" for true and "No" for false, making the data more user-friendly.
    <template #cell-is_married="{ value }">
      {{ value ? "Yes" : "No" }}
    </template>

By using these slots:

  • We take advantage of the scoped slot's local data (value) to dynamically control how the content is rendered.

  • This approach allows for highly customizable and reusable tables while ensuring that the data displayed to users is clear and formatted according to requirements.

This demonstrates the power of Vue's slots and scoped slots in making a table component flexible and adaptable for various use cases.

Sidenote: Even if a column isn’t included in the tableColumns prop, you can still access its data in the rowClick event. For example, if your data includes an id field but you don’t want to display it in the table, you can still retrieve it from the row object when handling the @rowClick event. This keeps your table clean while still providing access to all necessary data for backend operations or other logic.

You can still customize this component further and add new features as needed. However, with the current implementation:

<script
  setup
  lang="ts"
  generic="T extends object , U extends Extract<keyof T, string>"
>
interface Props {
  tableData: T[];
  tableColumns: U[];
}

interface Emits {
  (event: "rowClick", value: T): void;
}

const emit = defineEmits<Emits>();
const props = defineProps<Props>();

function splitStringByUnderscore(str: string) {
  return str.split("_").join(" ");
}
</script>

<template>
  <table>
    <thead>
      <tr>
        <th v-for="col in tableColumns" :key="col">
          <slot :name="`header-${col}`">
            {{ splitStringByUnderscore(col) }}
          </slot>
        </th>
      </tr>
    </thead>
    <tbody>
      <tr
        v-for="(data, i) in tableData"
        :key="i"
        @click="emit('rowClick', data)"
      >
        <td v-for="(col, j) in tableColumns" :key="col + j">
          <slot :name="`cell-${col}`" :rowRecord="data" :value="data[col]">
            {{ data[col] }}
          </slot>
        </td>
      </tr>
    </tbody>
  </table>
</template>

<style>
table {
  width: 100%;
  border: 1px solid white;
  border-collapse: collapse;
  text-align: center;
}

td,
th {
  border: 1px solid white;
}
</style>

This component is already capable of handling 95% of what a table is typically required to do. It’s highly flexible, dynamic, and reusable, meeting the needs of most use cases.

Conclusion

In this article, we’ve built a reusable, dynamic table component using Vue 3 and TypeScript. The component is designed to handle various use cases, with features like:

  • Dynamically rendering table headers and rows based on provided props.

  • Leveraging Vue slots for customizable headers and cells.

  • Utilizing scoped slots to allow full control over the rendering of specific data.

  • Handling row interactions with an event system for greater flexibility.

With this implementation, the table can already meet 95% of the requirements for most applications. It’s flexible, type-safe, and can easily be extended with additional features like pagination, sorting, or filtering.

The power of Vue combined with TypeScript enables a clean and maintainable codebase while providing tools like type inference and slot name suggestions for a better developer experience.

Whether you’re building internal tools or user-facing applications, this reusable table component is a solid foundation. And as your requirements grow, you can continue enhancing it to fit your needs. The possibilities are endless!

Thank you for taking the time to read this article. I hope it has helped you build a reusable and dynamic table component with Vue and TypeScript. If you have any feedback, questions, or ideas for improvement, feel free to share them. Happy coding!

You can reach out to me on LinkedIn.
Link to the repository: https://github.com/abiodunolunu/resuable-table-vue-typescript

3
Subscribe to my newsletter

Read articles from Olunu Abiodun directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Olunu Abiodun
Olunu Abiodun