Building Interactive Tables with Filters/Sorting/Grouping for Websites
Interactive tables are the main interface for working with data in B2B applications. TanStack Table (formerly react-table) is the most flexible React solution with headless architecture.
TanStack Table v8
npm install @tanstack/react-table
import {
useReactTable, getCoreRowModel, getSortedRowModel,
getFilteredRowModel, getGroupedRowModel, getExpandedRowModel,
getPaginationRowModel, flexRender, createColumnHelper
} from '@tanstack/react-table';
interface Order {
id: string;
customerName: string;
status: OrderStatus;
total: number;
category: string;
createdAt: Date;
}
const columnHelper = createColumnHelper<Order>();
const columns = [
columnHelper.accessor('id', {
header: '№',
size: 80,
cell: ({ getValue }) => <code className="text-xs">{getValue().slice(0, 8)}</code>
}),
columnHelper.accessor('customerName', {
header: 'Customer',
filterFn: 'includesString'
}),
columnHelper.accessor('status', {
header: 'Status',
cell: ({ getValue }) => <StatusBadge status={getValue()} />,
filterFn: (row, columnId, filterValue) =>
filterValue.includes(row.getValue(columnId))
}),
columnHelper.accessor('total', {
header: 'Amount',
cell: ({ getValue }) => formatCurrency(getValue()),
sortDescFirst: true,
aggregationFn: 'sum',
aggregatedCell: ({ getValue }) => (
<strong>{formatCurrency(getValue<number>())}</strong>
)
}),
columnHelper.accessor('category', {
header: 'Category'
}),
columnHelper.accessor('createdAt', {
header: 'Date',
cell: ({ getValue }) => format(getValue(), 'dd.MM.yyyy HH:mm'),
sortingFn: 'datetime'
})
];
function OrdersTable({ data }) {
const [sorting, setSorting] = useState([{ id: 'createdAt', desc: true }]);
const [columnFilters, setColumnFilters] = useState([]);
const [globalFilter, setGlobalFilter] = useState('');
const [grouping, setGrouping] = useState<string[]>([]);
const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 25 });
const table = useReactTable({
data,
columns,
state: { sorting, columnFilters, globalFilter, grouping, pagination },
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
onGlobalFilterChange: setGlobalFilter,
onGroupingChange: setGrouping,
onPaginationChange: setPagination,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getGroupedRowModel: getGroupedRowModel(),
getExpandedRowModel: getExpandedRowModel(),
getPaginationRowModel: getPaginationRowModel()
});
return (
<div>
<input
value={globalFilter}
onChange={e => setGlobalFilter(e.target.value)}
placeholder="Search all fields..."
className="mb-4 px-3 py-2 border rounded-lg w-64"
/>
<div className="flex gap-2 mb-4">
<span className="text-sm text-gray-600">Grouping:</span>
{['status', 'category'].map(col => (
<button
key={col}
onClick={() => setGrouping(prev =>
prev.includes(col) ? prev.filter(c => c !== col) : [...prev, col]
)}
className={`text-xs px-2 py-1 rounded border ${
grouping.includes(col) ? 'bg-blue-50 border-blue-300' : 'border-gray-200'
}`}
>
{col}
</button>
))}
</div>
<div className="overflow-x-auto rounded-lg border border-gray-200">
<table className="w-full text-sm">
<thead className="bg-gray-50">
{table.getHeaderGroups().map(headerGroup => (
<tr key={headerGroup.id}>
{headerGroup.headers.map(header => (
<th key={header.id}
className="px-4 py-3 text-left font-medium text-gray-600 whitespace-nowrap"
style={{ width: header.getSize() }}
>
<div className="flex items-center gap-1">
<span
className={header.column.getCanSort() ? 'cursor-pointer select-none' : ''}
onClick={header.column.getToggleSortingHandler()}
>
{flexRender(header.column.columnDef.header, header.getContext())}
</span>
{header.column.getIsSorted() === 'asc' && ' ↑'}
{header.column.getIsSorted() === 'desc' && ' ↓'}
</div>
{header.column.getCanFilter() && (
<ColumnFilter column={header.column} />
)}
</th>
))}
</tr>
))}
</thead>
<tbody className="divide-y divide-gray-100">
{table.getRowModel().rows.map(row => (
<tr key={row.id}
className={`hover:bg-gray-50 ${row.getIsGrouped() ? 'bg-gray-50 font-medium' : ''}`}>
{row.getVisibleCells().map(cell => (
<td key={cell.id} className="px-4 py-3">
{cell.getIsGrouped() ? (
<button onClick={row.getToggleExpandedHandler()} className="flex items-center gap-1">
{row.getIsExpanded() ? '▼' : '▶'}
{flexRender(cell.column.columnDef.cell, cell.getContext())}
({row.subRows.length})
</button>
) : cell.getIsAggregated() ? (
flexRender(cell.column.columnDef.aggregatedCell ?? cell.column.columnDef.cell, cell.getContext())
) : flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
<TablePagination table={table} />
</div>
);
}
Server-Side Sorting and Filtering
For large tables (>10k rows) — load data from server:
function ServerSideTable() {
const [sorting, setSorting] = useState([]);
const [columnFilters, setColumnFilters] = useState([]);
const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 25 });
const { data, isLoading } = useQuery({
queryKey: ['orders', { sorting, columnFilters, pagination }],
queryFn: () => fetch('/api/orders?' + new URLSearchParams({
sort: sorting.map(s => `${s.id}:${s.desc ? 'desc' : 'asc'}`).join(','),
filters: JSON.stringify(columnFilters),
page: String(pagination.pageIndex + 1),
per_page: String(pagination.pageSize)
})).then(r => r.json())
});
const table = useReactTable({
data: data?.items ?? [],
rowCount: data?.total ?? 0,
manualSorting: true,
manualFiltering: true,
manualPagination: true,
// ...
});
}
Timeline
Table with sorting, filters and pagination — 1 week. Add grouping, server-side and export — another 1 week.







