DEV Community

Cover image for Creating a reusable table component with React-Table and Material UI
Serhat Genç
Serhat Genç

Posted on

Creating a reusable table component with React-Table and Material UI

Introduction

Tables are tools that we often use in our personal and business projects. A table is a structured dataset consisting of rows and columns. It allows us to easily read, filter, and search large-scale data.

I will show you how we can create a reusable table component using Tanstack's react-table and Material UI, and why we should use react-query where big data is used, such as tables.

The framework I will use is Next.js, but you can follow this article using any react framework of your choice.

First, let's start by installing the necessary packages for our project.



npm install @tanstack/react-table @tanstack/react-query @mui/material @emotion/react @emotion/styled



Enter fullscreen mode Exit fullscreen mode

In terms of being type-safe, I will use Typescript, and for my HTTP requests, I will use Axios. These two packages are not essential to install.


Creating the table

We will create a table index file in the components folder. This is where we will structure our table. The table accepts two props named data and columns. To separate the data into the correct columns, the objects of the column array must contain a property named accessorKey. This key must be the same as the property key of the related data of the objects in the data array.

For example, if there is email information in our data and this email information is sent with the email property key, this means that the accessorKey of our column will be email. An example of usage is shown in the following steps.

To map data and columns to the table, we get our mapping functions from the useReactTable hook provided by react-table. We mapped data and column elements between Material UI table components. It's easy to customize the styles of these pre-styled table components for yourself.

The table component should look like this.



// components/Table/index.tsx
import {
  Paper,
  Table as MuiTable,
  TableBody,
  TableCell,
  TableHead,
  TableRow,
} from "@mui/material";
import {
  ColumnDef,
  flexRender,
  getCoreRowModel,
  useReactTable,
} from "@tanstack/react-table";
import { FC } from "react";

interface TableProps {
  data: any[];
  columns: ColumnDef<any>[];
}

export const Table: FC<TableProps> = ({ data, columns }) => {
  const { getHeaderGroups, getRowModel } = useReactTable({
    data,
    columns,
    getCoreRowModel: getCoreRowModel(),
  });

  return (
    <Paper elevation={2} style={{ padding: "1rem 0px" }}>
      <MuiTable>
        <TableHead>
          {getHeaderGroups().map((headerGroup) => (
            <TableRow key={headerGroup.id}>
              {headerGroup.headers.map((header) => (
                <TableCell key={header.id}>
                  {header.isPlaceholder
                    ? null
                    : flexRender(
                        header.column.columnDef.header,
                        header.getContext()
                      )}
                </TableCell>
              ))}
            </TableRow>
          ))}
        </TableHead>
        <TableBody>
          {getRowModel().rows.map((row) => (
            <TableRow key={row.id}>
              {row.getVisibleCells().map((cell) => (
                <TableCell key={cell.id}>
                  {flexRender(cell.column.columnDef.cell, cell.getContext())}
                </TableCell>
              ))}
            </TableRow>
          ))}
        </TableBody>
      </MuiTable>
    </Paper>
  );
};


Enter fullscreen mode Exit fullscreen mode

Usage

Now let's use the table in the project to test it and then try to improve the table. I will be getting the data from an API provided by Gorest. After we got the data, we sent it to the table as a prop.

It is time to create the column array. As I said above, accessorKey is required, and the header property is what will be on that column's header. Header property is optional, and when you do not provide this property, the value here will take the string value you wrote to the accessorKey. If you want to change what is on the header, you can provide a string or JSX callback.

The cell is another useful property. We can provide a callback here to give a custom look to our data cells for the column we want. The callback we returned on the cell property is giving us lots of information and functionality with a parameter. By digging around that parameter, you can find lots of details about your table, rows, and columns.



//pages/columns.ts
import { Chip } from "@mui/material";
import { ColumnDef } from "@tanstack/react-table";

export const columns: ColumnDef<any, any>[] = [
  {
    accessorKey: "name",
    header: "Name",
  },
  {
    accessorKey: "email",
    header: "Email",
  },
  {
    accessorKey: "gender",
    header: "Gender",
  },
  {
    accessorKey: "status",
    header: "Status",
    cell: (row: any) => {
      return (
        <Chip
          label={row.getValue()}
          size="small"
          color={row.getValue() === "active" ? "primary" : "default"}
        />
      );
    },
  },
];


Enter fullscreen mode Exit fullscreen mode

Now let's give the column array to the table as a prop and see the final version of the table usage.



//pages/index.tsx
import { useEffect, useState } from "react";
import type { NextPage } from "next";
import axios from "axios";
import { Box } from "@mui/material";
import Table from "../components/Table";
import { columns } from "./columns";

const Home: NextPage = () => {
  const [users, setUsers] = useState<Api.Users.Data[] | undefined>(undefined);

  const fetchUsers = async () => {
    const { data } = await axios.get<Api.Users.FetchUsersResponse>(
      "/api/users"
    );

    setUsers(data.data);
  };

  useEffect(() => {
    fetchUsers();
  }, []);

  return (
    <Box padding={6}>{users && <Table data={users} columns={columns} />}</Box>
  );
};


Enter fullscreen mode Exit fullscreen mode
Table

Improving the table

To improve the table, we are going to add;

  • Pagination
  • Search field
  • Skeleton loading
  • Click row action
  • Custom header component
  • Memoization
  • React query for HTTP requests

Pagination

This is the server-side pagination implementation. The table will now take two new props named page and pageCount.

If we have information about the page count, react-table wants us to give the page count to the useReactTable hook. If we don't have the page count information, we can pass -1. We also need to set the manuelPagination property to true to handle the pagination manually.



const { getHeaderGroups, getRowModel } = useReactTable({
    data,
    columns,
    getCoreRowModel: getCoreRowModel(),
    manualPagination: true,
    pageCount,
  })


Enter fullscreen mode Exit fullscreen mode

The page prop is for exposing the current page value to the outside of the table as a callback function. Invoke this function on the pagination change function with the current page value. This will come in handy as we want to keep track of the current page value outside of the table.

We are going to keep the current page value inside the table component as a state.

Instead of using the nextPage or previousPage functions provided to us by useReactTable, Material UI has a pagination component that will do an excellent job of paginating the table. This component handles pagination functions under the hood, so it only needs a few props to work.

The pagination functions in useReactTable are mostly used for client-side pagination.

The pagination component takes these three props named; count, page, and onChange. Count is the total number of pages, page is the current page and onChange is a callback that fires when the state changes.



const handlePageChange = (
  event: ChangeEvent<unknown>,
  currentPage: number
) => {
  setPaginationPage(currentPage === 0 ? 1 : currentPage);
  page?.(currentPage === 0 ? 1 : currentPage);
};

{pageCount && page && (
  <Pagination
    count={pageCount}
    page={paginationPage}
    onChange={handlePageChange}
    color="primary"
  />
)}


Enter fullscreen mode Exit fullscreen mode

Search field

We will retrieve the table results based on what we type in the search field. On the handleSearchChange function, we invoke the search callback given to the table to return the typed value. Notice that wrapping around the handle function with a debounce function is crucial because we don't want to return every typed value. This can cause problems such as making requests to an API on every typed value.




const handleSearchChange = (
    e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
) => {
    search && search(e.target.value);
};

{search && (
  <TextField
    onChange={debounce(handleSearchChange, 1000)}
    size="small"
    label={searchLabel}
    margin="normal"
    variant="standard"
  />
)}


Enter fullscreen mode Exit fullscreen mode

Skeleton loading

The table will display skeleton loading depending on the isFetching prop. The skeleton count on the table defaults to 10, and it can be overridden by giving the skeletonCount prop. We can think of the skeleton count prop as a row count for the skeletons, but we need the column count as well to render it on the table properly. We can get the number of columns with the getAllColumns function provided by useReactTable. The table includes the option of a skeleton height prop as well.



const { getHeaderGroups, getRowModel, getAllColumns } = useReactTable({
  data,
  columns,
  getCoreRowModel: getCoreRowModel(),
  manualPagination: true,
  pageCount,
});

const skeletons = Array.from({ length: skeletonCount }, (x, i) => i);

const columnCount = getAllColumns().length;

{
  !isFetching ? (
    getRowModel().rows.map((row) => (
      <StyledTableRow key={row.id}>
        {row.getVisibleCells().map((cell) => (
          <TableCell key={cell.id}>
            {flexRender(cell.column.columnDef.cell, cell.getContext())}
          </TableCell>
        ))}
      </StyledTableRow>
    ))
  ) : (
    <>
      //the mapping part of the skeletons
      {skeletons.map((skeleton) => (
        <TableRow key={}>
          {Array.from({ length: columnCount }, (x, i) => i).map((elm) => (
            <TableCell key={elm}>
              <Skeleton height={skeletonHeight} />
            </TableCell>
          ))}
        </TableRow>
      ))}
    </>
  );
}


Enter fullscreen mode Exit fullscreen mode

Click row action

When we map the data to the table using the getRowModel function, we can get details about that row or cell. Just pass the onClickRow prop function with row and cell parameters to the onClick property of the cell.



getRowModel().rows.map((row) => (
  <StyledTableRow key={row.id}>
    {row.getVisibleCells().map((cell) => (
      <TableCell
        onClick={() => onClickRow?.(cell, row)}
        key={cell.id}
      >
        {flexRender(cell.column.columnDef.cell, cell.getContext())}
      </TableCell>
    ))}
  </StyledTableRow>
))


Enter fullscreen mode Exit fullscreen mode

Custom header component

This is in case of a need for a header component for the table. We can place it above the search field.



<Box paddingX="1rem">
  {headerComponent && <Box>{headerComponent}</Box>}
  {search && (
    <TextField
      onChange={debounce(handleSearchChange, 1000)}
      size="small"
      label={searchLabel}
      margin="normal"
      variant="standard"
    />
  )}
</Box>


Enter fullscreen mode Exit fullscreen mode

Memoization

We need to apply memoization to prevent possible re-renders or computationally expensive performance losses.

We are going to wrap the data, column, and headerComponent props into useMemo to prevent expensive re-calculation.



const memoizedData = useMemo(() => data, [data]);
const memoizedColumns = useMemo(() => columns, [columns]);
const memoisedHeaderComponent = useMemo(
  () => headerComponent,
  [headerComponent]
);


Enter fullscreen mode Exit fullscreen mode

Wrapping the table export with memo to prevent unnecessary re-renders. This way, the component only re-renders when the props are changed.



//...

const Table: FC<TableProps> = ({
  //...
}) => {
  //...

  return (
    //...
    )
};

export default memo(Table);


Enter fullscreen mode Exit fullscreen mode

React query

React-query is a fetching tool by Tanstack. React-query will cache the HTTP requests we make. Most of the time, the size of the data we will use in the table is considerably large, so we can prevent the delay in loading the data in the table by caching HTTP requests. You can see that after implementing the react-query, the HTTP requests you make are returned with a 304 code. This shows that the data is being read from the cache, or you can make a more detailed observation by installing @tanstack/react-query-devtools, one of the developer tools for react-query. Note that this tool is only available on the development build of the project.

The useQuery hook provides the cached data, the status of our request, and the error object for possible errors. The usage is simple; for the first parameter, provide a key or array of keys; for the second parameter, the fetcher function for your data; and for the third parameter, the options object for the react-query, which is optional. Any change to the keys will trigger the fetcher function, so it makes sense that we provide this key value with our current page or search value.

Since I don't want it to refetch every time I switch windows on the browser, I set the options object's refetchOnWindowFocus property to false. Let's set the keepPreviousData property to true for a smooth transition between the pages in the table.



const [currentPage, setCurrentPage] = useState<number | undefined>(1);
const [search, setSearch] = useState<string | undefined>("");

const { data, isFetching, isError, error, isSuccess } = useQuery<
    Api.Users.FetchUsersResponse,
    Error
  >(["users", currentPage, search], fetchUsers, {
    refetchOnWindowFocus: false,
    keepPreviousData: true,
  });


Enter fullscreen mode Exit fullscreen mode
Final version of the table

This was the end of my article. I hope this article helped you to build your reusable table.

If you want to take a look at the full code, here is the Github Repository

or CodeSandbox preview

Top comments (2)

Collapse
 
kouznedm profile image
Alexander Kuznetsov

Top quality!

Collapse
 
mcubico profile image
Mauricio Montoya Medrano

Hi, thanks for sharing, I had an error in the column.ts file, this should be named with the tsx extension