Create a reusable react-table component with Typescript
Fernando González Tostado
Posted on November 29, 2022
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
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>
);
};
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',
},
],
[]
);
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;
}
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;
}
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>
);
};
And the result would be this:
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>
);
};
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 />
The component now accepts a boolean props that displays footer conditionally
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>
);
};
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>
);
};
And we have this wonderful controllers ready to be used:
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>
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)} />;
};
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,
};
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('');
Update the useReactTable
options
// 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.
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
<Table data={dummyData()} columns={cols} showFooter showGlobalFilter />
But we could also pass one our the filter functions declared above e.g.
<Table data={dummyData()} columns={cols} showFooter showGlobalFilter filterFn={filterFns.contains} />
And there you go, we have our super useful debounced filter input 🤩.
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
- Sandbox
- TanStack
- Photo from Roman Mager in Unsplash
Posted on November 29, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.