Create a reusable react-table component with Typescript
Tags: react, typescript, tanstack
Published: 2022-11-29 01:29:35

Tables, we love tables, maybe not more than forms! —🤔—, and Typescript has made reusable components more safe, but at the same time typing them may be sometimes difficult. More when it's a third party library that it's not opinionated.

I love react-table —now Tanstack Table— and I've used it in other projects which used to be JS, so typing was not a problem.

However this time is different, I wanted to create a reusable typesafe table, and the documentation, while very complete, it's not opinionated, therefore it made me dig a bit more of possible ways to create this component.

Time to get hands on!

We'll require these two dependencies

```bash
npm i @tanstack/react-table @tanstack/match-sorter-utils
```

First we'll create the main structure for the table and the required parameters by the `useReactTable` hook.

I've used tailwind to style the table, but it's up to you to choose your styling strategy.

Note the interface `ReactTableProps` because this interface will check the validity of the data structure passed to the Table component.

```tsx
// Table.tsx
 
import { getCoreRowModel, useReactTable, flexRender } from '@tanstack/react-table';
import type { ColumnDef } from '@tanstack/react-table';
 
interface ReactTableProps<T extends object> {
 data: T[];
 columns: ColumnDef<T>[];
}
 
export const Table = <T extends object>({ data, columns }: ReactTableProps<T>) => {
 const table = useReactTable({
   data,
   columns,
   getCoreRowModel: getCoreRowModel(),
 });
 
 return (
   <div className="flex flex-col">
     <div className="overflow-x-auto sm:-mx-6 lg:-mx-8">
       <div className="inline-block min-w-full py-4 sm:px-6 lg:px-8">
         <div className="overflow-hidden p-2">
           <table className="min-w-full text-center">
             <thead className="border-b bg-gray-50">
               {table.getHeaderGroups().map((headerGroup) => (
                 <tr key={headerGroup.id}>
                   {headerGroup.headers.map((header) => (
                     <th key={header.id} className="px-6 py-4 text-sm font-medium text-gray-900">
                       {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
                     </th>
                   ))}
                 </tr>
               ))}
             </thead>
             <tbody>
               {table.getRowModel().rows.map((row) => (
                 <tr key={row.id} className='border-b" bg-white'>
                   {row.getVisibleCells().map((cell) => (
                     <td className="whitespace-nowrap px-6 py-4 text-sm font-light text-gray-900" key={cell.id}>
                       {flexRender(cell.column.columnDef.cell, cell.getContext())}
                     </td>
                   ))}
                 </tr>
               ))}
             </tbody>
           </table>
         </div>
       </div>
     </div>
   </div>
 );
};
```

Now the `columns` array. How would the required data and columns props should look?

This is a minimal approach of `columns`. But more defs could be added. See [here](Now the columns array. How would the required data and columns props should look?

This is a minimal approach of columns. But more defs could be added. See [here](https://tanstack.com/table/v8/docs/guide/column-defs).

```tsx
import { ColumnDef } from '@tanstack/react-table';
 
const cols = useMemo<ColumnDef<Item>[]>(
 () => [
   {
     header: 'Name',
     cell: (row) => row.renderValue(),
     accessorKey: 'name',
   },
   {
     header: 'Price',
     cell: (row) => row.renderValue(),
     accessorKey: 'price',
   },
   {
     header: 'Quantity',
     cell: (row) => row.renderValue(),
     accessorKey: 'quantity',
   },
 ],
 []
);
```

The `ColumnDef` type takes a generic type that would be the model of the items that we'll display in the table. `Item` looks like this:

```ts
type Item = {
 name: string;
 price: number;
 quantity: number;
}
```

We can use this seeder just to test that it works

```ts
const dummyData = () => {
 const items = [];
 for (let i = 0; i < 10; i++) {
   items.push({
     id: i,
     name: `Item ${i}`,
     price: 100,
     quantity: 1,
   });
 }
 return items;
}
```

And in the component we use the seeder as the `data` prop

```tsx
const AComponentThatUsesTable = () => {
 return (
   <div className="px-10 py-5 md:w-1/2 m-auto">
     <Table data={dummyData()} columns={cols} />
     {/* .... */}
   </div>
 );
};
```
And the result would be this:

![Image table 1](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/h2k90cd54tq84q6h4u5b.png)

Pretty, isn't it?

At the moment this table doesn't support a lot of personalization, luckily we can make it to support it quite easily thanks to the existing methods provided by react-table. 

Let's add the option to show conditionally a footer

```ts
interface ReactTableProps<T extends object> {
 // ...
 showFooter: boolean;
}
 
export const Table = <T extends object>({ data, columns, showFooter = true }: ReactTableProps<T>) => {
 // ...
 
 return (
   <div className="flex flex-col">
     <div className="overflow-x-auto sm:-mx-6 lg:-mx-8">
       <div className="inline-block min-w-full py-4 sm:px-6 lg:px-8">
         <div className="overflow-hidden p-2">
           <table className="min-w-full text-center">
             <thead className="border-b bg-gray-50">
             {/* ... */}
             </thead>
             <tbody>
             {/* ... */}  
             </tbody>
             {showFooter ? (
               <tfoot className="border-t bg-gray-50">
                 {table.getFooterGroups().map((footerGroup) => (
                   <tr key={footerGroup.id}>
                     {footerGroup.headers.map((header) => (
                       <th key={header.id} colSpan={header.colSpan}>
                         {header.isPlaceholder
                           ? null
                           : flexRender(header.column.columnDef.footer, header.getContext())}
                       </th>
                     ))}
                   </tr>
                 ))}
               </tfoot>
             ) : null}
           </table>
         </div>
       </div>
     </div>
   </div>
 );
};
```
Don't forget declaring the footer property in the columns array objects to display whatever we want to show in it

E.g.

```tsx
const cols = useMemo<ColumnDef<TableItem>[]>(
 () => [
   {
     header: 'Name',
     cell: (row) => row.renderValue(),
     accessorKey: 'name',
     footer: 'Total',
   },
   {
     header: 'Price',
     cell: (row) => row.renderValue(),
     accessorKey: 'price',
     footer: () => cartTotal,
   },
   {
     header: 'Quantity',
     cell: (row) => row.renderValue(),
     accessorKey: 'quantity',
   },
 ],
 [cartTotal]
);
 
{/* .... */}
 
<Table data={dummyData()} columns={cols} showFooter />
``` 
The component now accepts a boolean props that displays footer conditionally

![Image table 2](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/0vceyd68iotkuukchkcd.png)

Now let's implement something more meaningful, like navigation features —pagination included!

```tsx
import {
 useReactTable,
 getPaginationRowModel,
} from '@tanstack/react-table';
 
interface ReactTableProps<T extends object> {
 // ...
 showNavigation?: boolean;
}
 
export const Table = <T extends object>({
 // ...
 showNavigation = true,
}: ReactTableProps<T>) => {
 const table = useReactTable({
   // ...
   getPaginationRowModel: getPaginationRowModel(),
 });
 
 return (
   <div className="flex flex-col">
     <div className="overflow-x-auto sm:-mx-6 lg:-mx-8">
       <div className="inline-block min-w-full py-4 sm:px-6 lg:px-8">
         <div className="overflow-hidden p-2">
           {/* ... */}
           {showNavigation ? (
             <>
               <div className="h-2 mt-5" />
               <div className="flex items-center gap-2">
                 <button
                   className="cursor-pointer rounded border p-1"
                   onClick={() => table.setPageIndex(0)}
                   disabled={!table.getCanPreviousPage()}
                 >
                   {'<<'}
                 </button>
                 <button
                   className="cursor-pointer rounded border p-1"
                   onClick={() => table.previousPage()}
                   disabled={!table.getCanPreviousPage()}
                 >
                   {'<'}
                 </button>
                 <button
                   className="cursor-pointer rounded border p-1"
                   onClick={() => table.nextPage()}
                   disabled={!table.getCanNextPage()}
                 >
                   {'>'}
                 </button>
                 <button
                   className="cursor-pointer rounded border p-1"
                   onClick={() => table.setPageIndex(table.getPageCount() - 1)}
                   disabled={!table.getCanNextPage()}
                 >
                   {'>>'}
                 </button>
                 <span className="flex cursor-pointer items-center gap-1">
                   <div>Page</div>
                   <strong>
                     {table.getState().pagination.pageIndex + 1} of {table.getPageCount()}
                   </strong>
                 </span>
                 <span className="flex items-center gap-1">
                   | Go to page:
                   <input
                     type="number"
                     defaultValue={table.getState().pagination.pageIndex + 1}
                     onChange={(e) => {
                       const page = e.target.value ? Number(e.target.value) - 1 : 0;
                       table.setPageIndex(page);
                     }}
                     className="w-16 rounded border p-1"
                   />
                 </span>
                 <select
                   value={table.getState().pagination.pageSize}
                   onChange={(e) => {
                     table.setPageSize(Number(e.target.value));
                   }}
                 >
                   {[10, 20, 30, 40, 50].map((pageSize) => (
                     <option key={pageSize} value={pageSize}>
                       Show {pageSize}
                     </option>
                   ))}
                 </select>
                 <div className="h-4" />
               </div>
             </>
           ) : null}
         </div>
       </div>
     </div>
   </div>
 );
};
```
Pagination will also be displayed only if we pass the prop showPagination

```tsx
const AComponentThatUsesTable = () => {
 return (
   <div className="px-10 py-5 md:w-1/2 m-auto">
     <Table data={dummyData()} columns={cols} showFooter showPagination />
     {/* .... */}
   </div>
 );
};
```
And we have this wonderful controllers ready to be used:

![Image table 3](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/o3htqiv84kpjfatp4i8k.png)

You can change the `Show ${N}` options by changing this array in the `Table` component

```tsx
<select
  value={table.getState().pagination.pageSize}
  onChange={(e) => {
    table.setPageSize(Number(e.target.value));
  }}
>
  {/* change items per page in this array */}
  {[10, 20, 30, 40, 50].map((pageSize) => (
    <option key={pageSize} value={pageSize}>
      Show {pageSize}
    </option>
  ))}
</select>
```
Finally, a component that it's imprescindible in big tables, a filter input.

We'll use a debounced input, this way the filter won't be triggered at every key stroke of the input.

A reusable component would also be nice to keep our code DRYer and to take advantage of this strategy elsewhere in our project

```tsx
import { useEffect } from 'react';
import { useState } from 'react';
 
interface Props extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'onChange'> {
 value: string | number;
 onChange: (val: string | number) => void;
 debounceTime?: number;
}
 
export const DebouncedInput = ({ value: initialValue, onChange, debounceTime = 300, ...props }: Props) => {
 const [value, setValue] = useState(initialValue);
 
 // setValue if any initialValue changes
 useEffect(() => {
   setValue(initialValue);
 }, [initialValue]);
 
 // debounce onChange — triggered on every keypress
 useEffect(() => {
   const timeout = setTimeout(() => {
     onChange(value);
   }, debounceTime);
 
   return () => {
     clearTimeout(timeout);
   };
 }, [value, onChange, debounceTime]);
 
 return <input {...props} value={value} onChange={(e) => setValue(e.target.value)} />;
};
```

We can try with these initial functions. The fuzzy function will be our default filterFn when no other function is passed to the Table component. I took these functions from [Material-React-Table](https://github.com/KevinVandy/material-react-table/blob/main/src/filterFns.ts), a great repository for an opinionated ready-to-use table based on react-table.

```tsx
/* eslint-disable @typescript-eslint/no-explicit-any */
import { rankItem, rankings } from '@tanstack/match-sorter-utils';
import type { RankingInfo } from '@tanstack/match-sorter-utils';
import type { Row } from '@tanstack/react-table';
 
// most of table work acceptably well with this function
const fuzzy = <TData extends Record<string, any> = {}>(
 row: Row<TData>,
 columnId: string,
 filterValue: string | number,
 addMeta: (item: RankingInfo) => void
) => {
 const itemRank = rankItem(row.getValue(columnId), filterValue as string, {
   threshold: rankings.MATCHES,
 });
 addMeta(itemRank);
 return itemRank.passed;
};
 
//  if the value is falsy, then the columnFilters state entry for that filter will removed from that array.
// https://github.com/KevinVandy/material-react-table/discussions/223#discussioncomment-4249221
fuzzy.autoRemove = (val: any) => !val;
 
const contains = <TData extends Record<string, any> = {}>(
 row: Row<TData>,
 id: string,
 filterValue: string | number
) =>
 row
   .getValue<string | number>(id)
   .toString()
   .toLowerCase()
   .trim()
   .includes(filterValue.toString().toLowerCase().trim());
 
contains.autoRemove = (val: any) => !val;
 
const startsWith = <TData extends Record<string, any> = {}>(
 row: Row<TData>,
 id: string,
 filterValue: string | number
) =>
 row
   .getValue<string | number>(id)
   .toString()
   .toLowerCase()
   .trim()
   .startsWith(filterValue.toString().toLowerCase().trim());
 
startsWith.autoRemove = (val: any) => !val;
 
export const filterFns = {
 fuzzy,
 contains,
 startsWith,
};
```

Add a `filterFn` property to the interface and also in the `Table` component props

```tsx
interface ReactTableProps<T extends object> {
 data: T[];
 columns: ColumnDef<T>[];
 showFooter?: boolean;
 showNavigation?: boolean;
 showGlobalFilter?: boolean;
 filterFn?: FilterFn<T>;
}
 
export const Table = <T extends object>({
 data,
 columns,
 showFooter = true,
 showNavigation = true,
 showGlobalFilter = false,
 filterFn = filterFns.fuzzy,
}: ReactTableProps<T>) => {
 // this is the search value
 const [globalFilter, setGlobalFilter] = useState('');
```
Update the `useReactTable` options

```tsx
// table.tsx
 
const table = useReactTable({
 data,
 columns,
 //
 state: {
   globalFilter
 },
 getCoreRowModel: getCoreRowModel(),
 getFilteredRowModel: getFilteredRowModel(),
 getPaginationRowModel: getPaginationRowModel(),
 //
 onGlobalFilterChange: setGlobalFilter,
 globalFilterFn: filterFn,
});
```
And finally add the conditional jsx for the filter function.

```tsx
export const Table = <T extends object>({
 /* ... */
 
 return (
   <div className="flex flex-col">
     <div className="overflow-x-auto sm:-mx-6 lg:-mx-8">
       <div className="inline-block min-w-full py-4 sm:px-6 lg:px-8">
         <div className="overflow-hidden p-2">
           {showGlobalFilter ? (
             <DebouncedInput
               value={globalFilter ?? ''}
               onChange={(value) => setGlobalFilter(String(value))}
               className="font-lg border-block border p-2 shadow mb-2"
               placeholder="Search all columns..."
             />
           ) : null}
 /* ... */
```
Passing the showGlobalFilter prop as true should be enough

```tsx
     <Table data={dummyData()} columns={cols} showFooter showGlobalFilter />
```
But we could also pass one our the filter functions declared above e.g.

```tsx
<Table data={dummyData()} columns={cols} showFooter showGlobalFilter filterFn={filterFns.contains} />
```

And there you go, we have our super useful debounced filter input 🤩.

![Image table 4](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/oqs3716noxdj4ha19kmk.png)


And this was it. We can now make use of our reusable Table component wherever we want to and with fully type safety.

### Resources:

- [FilterFns](https://github.com/KevinVandy/material-react-table/blob/main/src/filterFns.ts)
- [Sandbox](https://codesandbox.io/s/tanstack-table-example-fully-controlled-wthmte?file=/src/main.tsx)
- [TanStack](https://tanstack.com/table/v8)
- Photo from <a href="https://unsplash.com/@roman_lazygeek?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText">Roman Mager</a> in <a href="https://unsplash.com/es/s/fotos/university-blackboard?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText">Unsplash</a>