How to build a custom React table component with Typescript (Part 1)
Favour Afolayan
Posted on June 16, 2024
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:
- Table Head - renders a that holds the headings for each column of the table
- Table Body - renders a that holds the rows that render the data for the table
- 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;
}
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",
},
];
Now, let's create the table header:
<TableHead>
<TableRow>
{columns.map((column) => (
<TableCell key={column.id}>{column.label}</TableCell>
))}
</TableRow>
</TableHead>
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>
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;
}
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",
},
];
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>
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>
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>
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.
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;
}
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 π
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;
}
And the one for a Country
is this:
interface Column {
id: keyof Country;
label: string;
}
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;
}
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[];
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[];
}
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 choiceNext, 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[];
}
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>
);
}
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} />;
}
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} />;
}
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.
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!
Posted on June 16, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.