Implementing Interactive Tables (DataTables/TanStack Table) on Website
A table with sorting and pagination is one of the most common frontend tasks, and one of the most often implemented poorly. jQuery DataTables is still found in legacy projects, but in modern React applications, TanStack Table (formerly react-table) has become the de facto standard — a headless library without built-in styles that gives complete control over markup.
Library Selection
TanStack Table v8 — for React/Vue/Solid/Svelte projects. Headless: no styles included, just logic. Size ~14 KB gzipped.
jQuery DataTables — justified only if site already uses jQuery and there's no reason to add React for one table. Otherwise — avoid.
AG Grid Community — when you need 100,000+ row virtualization, Excel export, and cell editing. Heavier, but more powerful.
TanStack Table: Basic Implementation
npm install @tanstack/react-table
Define columns and connect hook:
import {
createColumnHelper,
flexRender,
getCoreRowModel,
getSortedRowModel,
getPaginationRowModel,
getFilteredRowModel,
useReactTable,
SortingState,
} from '@tanstack/react-table'
type Order = {
id: string
customer: string
amount: number
status: 'pending' | 'paid' | 'cancelled'
createdAt: string
}
const columnHelper = createColumnHelper<Order>()
const columns = [
columnHelper.accessor('id', {
header: 'Order #',
cell: (info) => <span className="font-mono text-sm">{info.getValue()}</span>,
}),
columnHelper.accessor('customer', {
header: 'Customer',
enableSorting: true,
}),
columnHelper.accessor('amount', {
header: 'Amount',
cell: (info) => `${info.getValue().toLocaleString('en-US')} $`,
sortingFn: 'basic',
}),
columnHelper.accessor('status', {
header: 'Status',
cell: (info) => <StatusBadge status={info.getValue()} />,
enableSorting: false,
}),
columnHelper.accessor('createdAt', {
header: 'Date',
sortingFn: 'datetime',
}),
]
function OrdersTable({ data }: { data: Order[] }) {
const [sorting, setSorting] = useState<SortingState>([])
const [globalFilter, setGlobalFilter] = useState('')
const table = useReactTable({
data,
columns,
state: { sorting, globalFilter },
onSortingChange: setSorting,
onGlobalFilterChange: setGlobalFilter,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getFilteredRowModel: getFilteredRowModel(),
initialState: { pagination: { pageSize: 25 } },
})
return (
<div>
<input
value={globalFilter}
onChange={(e) => setGlobalFilter(e.target.value)}
placeholder="Search all fields..."
className="mb-4 w-64 border rounded px-3 py-2"
/>
<table className="w-full border-collapse">
<thead>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th
key={header.id}
onClick={header.column.getToggleSortingHandler()}
className={header.column.getCanSort() ? 'cursor-pointer select-none' : ''}
>
{flexRender(header.column.columnDef.header, header.getContext())}
{{ asc: ' ↑', desc: ' ↓' }[header.column.getIsSorted() as string] ?? ''}
</th>
))}
</tr>
))}
</thead>
<tbody>
{table.getRowModel().rows.map((row) => (
<tr key={row.id} className="hover:bg-gray-50">
{row.getVisibleCells().map((cell) => (
<td key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))}
</tbody>
</table>
<div className="flex items-center gap-2 mt-4">
<button onClick={() => table.previousPage()} disabled={!table.getCanPreviousPage()}>
←
</button>
<span>
Page {table.getState().pagination.pageIndex + 1} of {table.getPageCount()}
</span>
<button onClick={() => table.nextPage()} disabled={!table.getCanNextPage()}>
→
</button>
<select
value={table.getState().pagination.pageSize}
onChange={(e) => table.setPageSize(Number(e.target.value))}
>
{[10, 25, 50, 100].map((size) => (
<option key={size} value={size}>per {size}</option>
))}
</select>
</div>
</div>
)
}
Server-Side Pagination
For large datasets (10,000+ rows), client-side pagination doesn't work — everything must go through API:
const [{ pageIndex, pageSize }, setPagination] = useState({
pageIndex: 0,
pageSize: 25,
})
// React Query for data loading
const { data, isFetching } = useQuery({
queryKey: ['orders', pageIndex, pageSize, sorting, globalFilter],
queryFn: () =>
fetchOrders({
page: pageIndex + 1,
limit: pageSize,
sortBy: sorting[0]?.id,
sortDir: sorting[0]?.desc ? 'desc' : 'asc',
search: globalFilter,
}),
keepPreviousData: true, // doesn't flicker when switching pages
})
const table = useReactTable({
data: data?.rows ?? [],
columns,
pageCount: data?.pageCount ?? -1,
state: { sorting, pagination: { pageIndex, pageSize }, globalFilter },
manualPagination: true, // key flag
manualSorting: true,
manualFiltering: true,
onPaginationChange: setPagination,
// ...
})
Export to CSV
function exportToCSV(table: Table<Order>) {
const headers = table.getAllColumns()
.filter((col) => col.getIsVisible())
.map((col) => col.columnDef.header as string)
const rows = table.getFilteredRowModel().rows.map((row) =>
row.getVisibleCells().map((cell) => {
const value = cell.getValue()
return typeof value === 'string' && value.includes(',') ? `"${value}"` : value
})
)
const csv = [headers, ...rows].map((r) => r.join(',')).join('\n')
const blob = new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `orders-${Date.now()}.csv`
a.click()
URL.revokeObjectURL(url)
}
\uFEFF — BOM for proper Cyrillic display in Excel.
Row Virtualization
For tables with thousands of rows on client — TanStack Virtual:
npm install @tanstack/react-virtual
import { useVirtualizer } from '@tanstack/react-virtual'
const tableContainerRef = useRef<HTMLDivElement>(null)
const { rows } = table.getRowModel()
const rowVirtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => tableContainerRef.current,
estimateSize: () => 48, // row height in px
overscan: 10,
})
// In JSX:
<div ref={tableContainerRef} style={{ height: '600px', overflow: 'auto' }}>
<table>
<tbody style={{ height: `${rowVirtualizer.getTotalSize()}px`, position: 'relative' }}>
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
const row = rows[virtualRow.index]
return (
<tr
key={row.id}
style={{
position: 'absolute',
top: 0,
transform: `translateY(${virtualRow.start}px)`,
height: `${virtualRow.size}px`,
}}
>
{row.getVisibleCells().map((cell) => (
<td key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
)
})}
</tbody>
</table>
</div>
10,000 rows render in ~20 ms — only what's visible in viewport is in DOM.
What We Do
Analyze data structure and requirements: volume, update frequency, need inline-edit, export, fixed columns. Choose client-side or server-side pagination model for the task, configure columns, sorting, filtering. Style according to project design system — table looks like part of the interface, not a widget from another app.
Timeframe: basic table with sorting and pagination — 1 day. With server-side pagination, filters, and export — 2–3 days.







