DEV Community

Cover image for Create a reusable react-table component with Typescript
Fernando González Tostado
Fernando González Tostado

Posted on • Updated on

Create a reusable react-table component with Typescript

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

npm i @tanstack/react-table @tanstack/match-sorter-utils
Enter fullscreen mode Exit fullscreen mode

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.

// 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>
 );
};
Enter fullscreen mode Exit fullscreen mode

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.

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',
   },
 ],
 []
);
Enter fullscreen mode Exit fullscreen mode

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:

type Item = {
 name: string;
 price: number;
 quantity: number;
}
Enter fullscreen mode Exit fullscreen mode

We can use this seeder just to test that it works

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

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

const AComponentThatUsesTable = () => {
 return (
   <div className="px-10 py-5 md:w-1/2 m-auto">
     <Table data={dummyData()} columns={cols} />
     {/* .... */}
   </div>
 );
};
Enter fullscreen mode Exit fullscreen mode

And the result would be this:

Image table 1

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

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>
 );
};
Enter fullscreen mode Exit fullscreen mode

Don't forget declaring the footer property in the columns array objects to display whatever we want to show in it

E.g.

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 />
Enter fullscreen mode Exit fullscreen mode

The component now accepts a boolean props that displays footer conditionally

Image table 2

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

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>
 );
};
Enter fullscreen mode Exit fullscreen mode

Pagination will also be displayed only if we pass the prop showPagination

const AComponentThatUsesTable = () => {
 return (
   <div className="px-10 py-5 md:w-1/2 m-auto">
     <Table data={dummyData()} columns={cols} showFooter showPagination />
     {/* .... */}
   </div>
 );
};
Enter fullscreen mode Exit fullscreen mode

And we have this wonderful controllers ready to be used:

Image table 3

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

<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>
Enter fullscreen mode Exit fullscreen mode

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

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)} />;
};
Enter fullscreen mode Exit fullscreen mode

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, a great repository for an opinionated ready-to-use table based on react-table.

/* 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,
};
Enter fullscreen mode Exit fullscreen mode

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

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('');
Enter fullscreen mode Exit fullscreen mode

Update the useReactTable options

// table.tsx

const table = useReactTable({
 data,
 columns,
 //
 state: {
   globalFilter
 },
 getCoreRowModel: getCoreRowModel(),
 getFilteredRowModel: getFilteredRowModel(),
 getPaginationRowModel: getPaginationRowModel(),
 //
 onGlobalFilterChange: setGlobalFilter,
 globalFilterFn: filterFn,
});
Enter fullscreen mode Exit fullscreen mode

And finally add the conditional jsx for the filter function.

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}
 /* ... */
Enter fullscreen mode Exit fullscreen mode

Passing the showGlobalFilter prop as true should be enough

     <Table data={dummyData()} columns={cols} showFooter showGlobalFilter />
Enter fullscreen mode Exit fullscreen mode

But we could also pass one our the filter functions declared above e.g.

<Table data={dummyData()} columns={cols} showFooter showGlobalFilter filterFn={filterFns.contains} />
Enter fullscreen mode Exit fullscreen mode

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

Image table 4

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

Resources:

Top comments (0)