How to build a custom React table component with Typescript (Part 1)

igbominadeveloper

Favour Afolayan

Posted on June 16, 2024

How to build a custom React table component with Typescript (Part 1)

Introduction

The purpose of this article is to show you in a simple way, how to create a fully-typed, reusable Table component. For this, we will be using the Material UI (MUI) Library React table component: https://mui.com/material-ui/react-table to move fast without writing any styling but this could be applied to any table component library

A few things to help you understand how the MUI Library Table component works:

  1. Table Head - renders a that holds the headings for each column of the table
  2. Table Body - renders a that holds the rows that render the data for the table
  3. Pagination (optional) - library-provided pagination component - we won't cover it because it's not our focus in this article

Let's look at the basic components that make each of the parts of a table as mentioned above:

Table Head

This is a component that renders the <th></th> element for each column and wraps it with the right html tags to construct a table header. Each column of the table has a heading <th></th>. So let's define the shape of each column so we can build it out:

interface Column {
  id: string;
  label: string;
}
Enter fullscreen mode Exit fullscreen mode

Let's model the Column type and create the columns array that we will use to create the Table Header:

const columns: Column[] = [
  { id: "name", label: "Name" },
  { id: "email", label: "Email address" },
  {
    id: "phoneNumber",
    label: "Phone Number",
  },
  {
    id: "age",
    label: "Age",
  },
];
Enter fullscreen mode Exit fullscreen mode

Now, let's create the table header:

<TableHead>
  <TableRow>
    {columns.map((column) => (
      <TableCell key={column.id}>{column.label}</TableCell>
    ))}
  </TableRow>
</TableHead>
Enter fullscreen mode Exit fullscreen mode

And this will take the labels of each column provided to render this:

<thead>
  <tr>
    <th>Name</th>
    <th>Email address</th>
    <th>Phone Number</th>
    <th>Age</th>
  </tr>
</thead>
Enter fullscreen mode Exit fullscreen mode

Table Body

The body of our table is really not different from the html table body. We will import the TableBody component from MUI - this renders the <tbody></tbody> element. And then we just provide the rows of the data that it would need to construct the <td></td> for each row of the table.

To achieve this, we will have an interface that mirrors the structure of the data we want to render. In our case, we will model a Table of Person data:

interface Person {
  name: string;
  email: string;
  age: number;
  phoneNumber: string;
}
Enter fullscreen mode Exit fullscreen mode

And let's create some sample data to use with it:

const rows: Person[] = [
  {
    name: "John Doe",
    email: "john.doe@example.com",
    age: 25,
    phoneNumber: "+1234567890",
  },
  {
    name: "Jane Smith",
    email: "jane.smith@example.com",
    age: 30,
    phoneNumber: "+1987654321",
  },
  {
    name: "Alice Johnson",
    email: "alice.johnson@example.com",
    age: 35,
    phoneNumber: "+1122334455",
  },
];
Enter fullscreen mode Exit fullscreen mode

Now, let's create the table body:

<TableBody>
  {rows.map((row) => (
    <TableRow key={row.name}>
      {columns.map((column) => (
        <TableCell key={column.id}>{row[column.id]}</TableCell>
      ))}
    </TableRow>
  ))}
</TableBody>
Enter fullscreen mode Exit fullscreen mode

And this will take each object in the array we provided to render this:

<tbody>
  <tr>
    <td>John Doe</td>
    <td>john.doe@example.com</td>
    <td>25</td>
    <td>+1234567890</td>
  </tr>
  ...more rows as is provided in the array
</tbody>
Enter fullscreen mode Exit fullscreen mode

And now, we can combine it all to render the whole table.

<TableContainer>
  <Table>
    <TableHead>
      <TableRow>
        {columns.map((column) => (
          <TableCell key={column.id}>{column.label}</TableCell>
        ))}
      </TableRow>
    </TableHead>
    <TableBody>
      {rows.map((row) => (
        <TableRow key={row.name}>
          {columns.map((column) => (
            <TableCell key={column.id}>{row[column.id]}</TableCell>
          ))}
        </TableRow>
      ))}
    </TableBody>
  </Table>
</TableContainer>
Enter fullscreen mode Exit fullscreen mode

You can play around with the code here or click here to view how it looks in the browser

P.S. You can click through the links on the page to see all the tables. If you check the contents of each component, you will notice similarities between them. Actually, the major difference is the structure of the data and the columns of the table. This shows an opportunity to refactor our code to reduce duplication by creating a reusable table component

But before we proceed to do this, if you look at this part of the TableBody inside Person.tsx here, you will notice there is a typescript warning there.

TS error - wrong array index

This is because to access an object using the index notation in typescript, you have to use an index that matches an existing property of the object. In our case, we defined column.id as a string and that's why we are getting this error: No index signature with a parameter of type 'string' was found on type 'Data'..

Since the id property of each column we have matches each property of the objects we provided in the array of rows, we can easily change the type of our Column.id to match this.

interface Column {
  id: keyof Person;
  label: string;
}
Enter fullscreen mode Exit fullscreen mode

And now with this simple change, for each column object i.e. { id: 'name', label: 'Name' }, we can use the id it to access each Person object in the array of rows.

Here is what our final code looks like:

And with the wave of our TS wand, we can see that the error has vanished into thin air πŸ˜€

TS Error fixed - object index

And with that fix, we are ready to proceed to create a component we can resuse to render the People and Countries pages respectively and re-use as many things as we can.

Create a Table component

Before we discuss how I think we should start approaching this, I would like to point out the problem first and then we can look at a quick solution we can consider:
If you take a look at our Countries.tsx and People.tsx files, you will notice that the visible difference is just table header labels and the structure of the objects in the rows array.
This means we can abstract away the repetitve lines of code and reuse them from inside a component.

As discussed earlier, since our table is made up of two parts (Header and Body), we will start by breaking this component into these two parts.

To make our CustomTable component configurable from outside it, we have to make provision to allow this using Props. If you find Props strange, You can visit the React Documentation to read up on this and then come back to this article.

Since this is a Typescript project, here is what I like to do first, define the interface of our Props

The interface for a Person is this:

interface Column {
  id: keyof Person;
  label: string;
}
Enter fullscreen mode Exit fullscreen mode

And the one for a Country is this:

interface Column {
  id: keyof Country;
  label: string;
}
Enter fullscreen mode Exit fullscreen mode

A quick look at the above code will reveal that the only difference is the type of the id inside each column. We need to find a way to create a Column that would be generic enough to handle any type. And that's where Typescript Generics will come handy as it allows us to create a dynamic types that can be re-usable without being tied to a particular type:
Here is what I mean by that:

interface Column<T> {
  id: keyof T;
  label: string;
}
Enter fullscreen mode Exit fullscreen mode

We just created a Column interface that expects anyone using it to provide the type to it and it can use it to add type safety wherever it is used.

And for our rows, since it's just an array of objects, it's pretty straightforward too.
it would just be this:

type Row = T[];
Enter fullscreen mode Exit fullscreen mode

And now, we can combine the two to form the shape of the prop to pass to the CustomTable

interface Props<T> {
  columns: Column<T>[];
  rows: T[];
}
Enter fullscreen mode Exit fullscreen mode

Since we have got that out of the way, let's use this to create a reusable CustomTable component:

  • Let's start by creating a file: table.tsx.
    P.S You can give yours any name of your choice

  • Next, we will add the data types we declared above at the top of the file.

export interface Column<T> {
  id: keyof T;
  label: string;
}

interface Props<T> {
  columns: Column<T>[];
  rows: T[];
}
Enter fullscreen mode Exit fullscreen mode

NOTE: we added the export statement to the Column interface because we need to use it inside the Countries.tsx and People.tsx files to provide type inference for the rows and columns arrays

Also, there is no point creating a dedicated type for the rows property since it's just an array of the type provided i.e. T[] and that's why I used it directly.

  • Next, we want to create the function that would be exported as the component. We will also use the props provided to the component to access the rows and columns.
export default function CustomTable<T>(props: Props<T>) {
  const { columns, rows } = props;

  return (
    <Paper>
      <TableContainer>
        <Table>
          <TableHead>
            <TableRow>
              {columns.map((column, index) => (
                <TableCell key={index}>{column.label}</TableCell>
              ))}
            </TableRow>
          </TableHead>
          <TableBody>
            {rows.map((row, index) => {
              return (
                <TableRow key={index}>
                  {columns.map((column, index) => {
                    return <TableCell key={index}>{row[column.id]}</TableCell>;
                  })}
                </TableRow>
              );
            })}
          </TableBody>
        </Table>
      </TableContainer>
    </Paper>
  );
}
Enter fullscreen mode Exit fullscreen mode

NOTE: I used the index as the keys of the array I mapped through here because I am sure the items are not going to change during each render. If you want to know why using array indexes as keys could be a bad idea, you can read about it here

Now, we can use the component we created to re-build our two tables Countries.tsx and People.tsx.

People.tsx

import CustomTable, { Column } from "./custom-table";

interface Person {
  name: string;
  email: string;
  age: number;
  phoneNumber: string;
}

const columns: Column<Person>[] = [
  { id: "name", label: "Name" },
  { id: "email", label: "Email address" },
  {
    id: "phoneNumber",
    label: "Phone Number",
  },
  {
    id: "age",
    label: "Age",
  },
];

const rows: Person[] = [
  {
    name: "John Doe",
    email: "john.doe@example.com",
    age: 25,
    phoneNumber: "+1234567890",
  },
  {
    name: "Jane Smith",
    email: "jane.smith@example.com",
    age: 30,
    phoneNumber: "+1987654321",
  },
  {
    name: "Alice Johnson",
    email: "alice.johnson@example.com",
    age: 35,
    phoneNumber: "+1122334455",
  },
];

export default function People() {
  return <CustomTable columns={columns} rows={rows} />;
}
Enter fullscreen mode Exit fullscreen mode

Countries.tsx

import CustomTable, { Column } from "./custom-table";

interface Country {
  name: string;
  code: string;
  population: number;
  size: number;
  density: number;
}

const columns: Column<Country>[] = [
  { id: "name", label: "Name" },
  { id: "code", label: "ISO\u00a0Code" },
  {
    id: "population",
    label: "Population",
  },
  {
    id: "size",
    label: "Size\u00a0(km\u00b2)",
  },
  {
    id: "density",
    label: "Density",
  },
];
const rows: Country[] = [
  {
    name: "India",
    code: "IN",
    population: 1324171354,
    size: 3287263,
    density: 1324171354 / 3287263, // ideally, should be computed dynamically by dividing each population by size,
  },
  {
    name: "China",
    code: "CN",
    population: 1403500365,
    size: 9596961,
    density: 1403500365 / 9596961,
  },
  {
    name: "Italy",
    code: "IT",
    population: 60483973,
    size: 301340,
    density: 60483973 / 301340,
  },
];

export default function Countries() {
  return <CustomTable columns={columns} rows={rows} />;
}
Enter fullscreen mode Exit fullscreen mode

Here is the final code if you want to check it out:

Now we can render a million different tables, all we have to do is provide the rows and the column configuration.

Extending the table functionality

Now, what we have created so far works well for almost every table if it's just to render some data which is what most tables do. But imagine if you had to render your table rows in somewhat similar fashion to this table.

Table with a custom column

To be able to support this behavior, we have to extend the possibilities of our CustomTable component but I won't cover that right now.

Conclusion

In this article, we've taken a detailed look at how to create a fully-typed, reusable Table component leveraging the styling the Material UI (MUI) Library in React provides. We started by understanding the basic structure of a table, focusing on the Table Head and Table Body components. We explored how to define and use TypeScript interfaces to model our data, ensuring type safety and reducing errors.

We walked through the creation of a simple table, then moved on to build a more complex, reusable CustomTable component. This component uses TypeScript generics to handle different data types, making it flexible and versatile for various use cases. By creating a CustomTable, we streamlined our code, eliminating redundancy and making it easier to manage and extend.

We also discussed how to handle TypeScript warnings and errors, ensuring our code remains clean and maintainable. Finally, we demonstrated how to implement this reusable component in different contexts, such as the People and Countries tables.

This approach not only saves time but also enhances code readability and maintainability. The reusable component can be extended further to support more advanced features and customizations, which we will cover in part two of this series.

I hope you found this guide helpful and that you learned something new about creating reusable components in React with TypeScript. Please feel free to leave your comments and questions below. Thanks for reading!

πŸ’– πŸ’ͺ πŸ™… 🚩
igbominadeveloper
Favour Afolayan

Posted on June 16, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related