TanStack Table v8: Sorting, Filtering, Pagination, Virtualization, and TypeScript
Build production data tables with TanStack Table v8: type-safe column definitions, client-side sorting and filtering, server-side pagination, row virtualization for large datasets, and editable cells.
TanStack Table v8 is headless โ it gives you state management and logic for sorting, filtering, pagination, grouping, and virtualization, but zero UI. You render the table yourself with whatever CSS framework you use. The result is total control over the look and feel, with none of the limitations of bundled UI table components.
This post covers the practical patterns: type-safe column definitions, client-side and server-side data modes, column sorting and filtering, row selection, and virtualization with TanStack Virtual for tables with 10,000+ rows.
Installation
npm install @tanstack/react-table @tanstack/react-virtual
1. Column Definitions and Basic Table
// src/components/tables/OrdersTable.tsx
'use client';
import {
useReactTable,
getCoreRowModel,
getSortedRowModel,
getFilteredRowModel,
getPaginationRowModel,
flexRender,
createColumnHelper,
type SortingState,
type ColumnFiltersState,
} from '@tanstack/react-table';
import { useState } from 'react';
import { ArrowUpDown, ChevronUp, ChevronDown } from 'lucide-react';
interface Order {
id: string;
customerName: string;
email: string;
status: 'pending' | 'processing' | 'shipped' | 'delivered' | 'cancelled';
totalCents: number;
createdAt: Date;
}
// Column helper provides type-safe column definitions
const columnHelper = createColumnHelper<Order>();
const columns = [
columnHelper.accessor('id', {
header: 'Order ID',
cell: (info) => (
<span className="font-mono text-xs text-gray-500">
#{info.getValue().slice(-6).toUpperCase()}
</span>
),
enableSorting: false,
}),
columnHelper.accessor('customerName', {
header: ({ column }) => (
<button
className="flex items-center gap-1 font-medium"
onClick={() => column.toggleSorting()}
>
Customer
{column.getIsSorted() === 'asc' ? (
<ChevronUp className="h-3 w-3" />
) : column.getIsSorted() === 'desc' ? (
<ChevronDown className="h-3 w-3" />
) : (
<ArrowUpDown className="h-3 w-3 text-gray-400" />
)}
</button>
),
}),
columnHelper.accessor('email', {
header: 'Email',
cell: (info) => <span className="text-gray-500 text-sm">{info.getValue()}</span>,
}),
columnHelper.accessor('status', {
header: 'Status',
cell: (info) => {
const status = info.getValue();
const colors: Record<Order['status'], string> = {
pending: 'bg-yellow-100 text-yellow-700',
processing: 'bg-blue-100 text-blue-700',
shipped: 'bg-purple-100 text-purple-700',
delivered: 'bg-green-100 text-green-700',
cancelled: 'bg-red-100 text-red-700',
};
return (
<span className={`rounded-full px-2.5 py-0.5 text-xs font-medium ${colors[status]}`}>
{status.charAt(0).toUpperCase() + status.slice(1)}
</span>
);
},
filterFn: 'equals',
}),
columnHelper.accessor('totalCents', {
header: ({ column }) => (
<button className="flex items-center gap-1 font-medium" onClick={() => column.toggleSorting()}>
Total
{column.getIsSorted() === 'asc' ? <ChevronUp className="h-3 w-3" /> :
column.getIsSorted() === 'desc' ? <ChevronDown className="h-3 w-3" /> :
<ArrowUpDown className="h-3 w-3 text-gray-400" />}
</button>
),
cell: (info) =>
new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(
info.getValue() / 100
),
}),
columnHelper.accessor('createdAt', {
header: 'Date',
cell: (info) =>
new Date(info.getValue()).toLocaleDateString('en-US', {
month: 'short', day: 'numeric', year: 'numeric',
}),
sortingFn: 'datetime',
}),
];
export function OrdersTable({ data }: { data: Order[] }) {
const [sorting, setSorting] = useState<SortingState>([]);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [globalFilter, setGlobalFilter] = useState('');
const table = useReactTable({
data,
columns,
state: { sorting, columnFilters, globalFilter },
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
onGlobalFilterChange: setGlobalFilter,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
initialState: {
pagination: { pageSize: 20 },
},
});
return (
<div className="space-y-4">
{/* Global search */}
<div className="flex items-center gap-3">
<input
value={globalFilter}
onChange={(e) => setGlobalFilter(e.target.value)}
placeholder="Search orders..."
className="h-9 w-64 rounded-lg border border-gray-300 px-3 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
{/* Status filter */}
<select
value={(table.getColumn('status')?.getFilterValue() as string) ?? ''}
onChange={(e) =>
table.getColumn('status')?.setFilterValue(e.target.value || undefined)
}
className="h-9 rounded-lg border border-gray-300 px-3 text-sm"
>
<option value="">All statuses</option>
<option value="pending">Pending</option>
<option value="processing">Processing</option>
<option value="shipped">Shipped</option>
<option value="delivered">Delivered</option>
<option value="cancelled">Cancelled</option>
</select>
</div>
{/* Table */}
<div className="overflow-x-auto rounded-lg border border-gray-200">
<table className="w-full text-sm">
<thead className="bg-gray-50 border-b border-gray-200">
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th key={header.id} className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
</th>
))}
</tr>
))}
</thead>
<tbody className="divide-y divide-gray-100">
{table.getRowModel().rows.map((row) => (
<tr key={row.id} className="hover:bg-gray-50 transition-colors">
{row.getVisibleCells().map((cell) => (
<td key={cell.id} className="px-4 py-3 text-gray-700">
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
{/* Pagination */}
<div className="flex items-center justify-between">
<p className="text-sm text-gray-500">
{table.getFilteredRowModel().rows.length} results
{table.getPageCount() > 1 && ` ยท Page ${table.getState().pagination.pageIndex + 1} of ${table.getPageCount()}`}
</p>
<div className="flex items-center gap-2">
<button
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
className="rounded border px-3 py-1 text-sm disabled:opacity-40"
>
Previous
</button>
<button
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
className="rounded border px-3 py-1 text-sm disabled:opacity-40"
>
Next
</button>
<select
value={table.getState().pagination.pageSize}
onChange={(e) => table.setPageSize(Number(e.target.value))}
className="rounded border px-2 py-1 text-sm"
>
{[10, 20, 50, 100].map((s) => <option key={s} value={s}>{s} per page</option>)}
</select>
</div>
</div>
</div>
);
}
๐ Looking for a Dev Team That Actually Delivers?
Most agencies sell you a project manager and assign juniors. Viprasol is different โ senior engineers only, direct Slack access, and a 5.0โ Upwork record across 100+ projects.
- React, Next.js, Node.js, TypeScript โ production-grade stack
- Fixed-price contracts โ no surprise invoices
- Full source code ownership from day one
- 90-day post-launch support included
2. Server-Side Pagination and Sorting
// src/components/tables/ServerSideOrdersTable.tsx
'use client';
import { useQuery } from '@tanstack/react-query';
import { useState } from 'react';
interface FetchOrdersParams {
page: number;
pageSize: number;
sortBy: string;
sortDir: 'asc' | 'desc';
search: string;
status: string;
}
async function fetchOrders(params: FetchOrdersParams) {
const query = new URLSearchParams({
page: params.page.toString(),
pageSize: params.pageSize.toString(),
sortBy: params.sortBy,
sortDir: params.sortDir,
...(params.search && { search: params.search }),
...(params.status && { status: params.status }),
});
return fetch(`/api/orders?${query}`).then((r) => r.json()) as Promise<{
data: Order[];
total: number;
page: number;
pageCount: number;
}>;
}
export function ServerSideOrdersTable() {
const [sorting, setSorting] = useState<SortingState>([{ id: 'createdAt', desc: true }]);
const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 20 });
const [globalFilter, setGlobalFilter] = useState('');
const [statusFilter, setStatusFilter] = useState('');
const { data, isLoading, isFetching } = useQuery({
queryKey: ['orders', { sorting, pagination, globalFilter, statusFilter }],
queryFn: () => fetchOrders({
page: pagination.pageIndex + 1,
pageSize: pagination.pageSize,
sortBy: sorting[0]?.id ?? 'createdAt',
sortDir: sorting[0]?.desc ? 'desc' : 'asc',
search: globalFilter,
status: statusFilter,
}),
placeholderData: (prev) => prev, // Keep previous data while fetching
});
const table = useReactTable({
data: data?.data ?? [],
columns,
state: { sorting, pagination },
onSortingChange: setSorting,
onPaginationChange: setPagination,
getCoreRowModel: getCoreRowModel(),
manualSorting: true, // โ Server handles sorting
manualPagination: true, // โ Server handles pagination
pageCount: data?.pageCount ?? -1,
rowCount: data?.total,
});
return (
<div className={isFetching ? 'opacity-75 transition-opacity' : ''}>
{/* ... same table UI ... */}
</div>
);
}
3. Row Virtualization for Large Datasets
// For 10,000+ rows: virtualize with @tanstack/react-virtual
import { useVirtualizer } from '@tanstack/react-virtual';
import { useRef } from 'react';
export function VirtualizedTable({ data }: { data: Order[] }) {
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
// Don't paginate โ render all (virtually)
});
const rows = table.getRowModel().rows;
const tableContainerRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => tableContainerRef.current,
estimateSize: () => 48, // Row height in px
overscan: 10, // Render 10 extra rows outside viewport
});
const virtualRows = virtualizer.getVirtualItems();
const totalSize = virtualizer.getTotalSize();
// Padding to simulate full list height
const paddingTop = virtualRows.length > 0 ? virtualRows[0].start : 0;
const paddingBottom = virtualRows.length > 0
? totalSize - (virtualRows[virtualRows.length - 1].end)
: 0;
return (
<div
ref={tableContainerRef}
className="overflow-auto rounded-lg border border-gray-200"
style={{ height: '600px' }} // Fixed height required for virtualization
>
<table className="w-full text-sm">
<thead className="sticky top-0 bg-gray-50 z-10 border-b border-gray-200">
{table.getHeaderGroups().map((hg) => (
<tr key={hg.id}>
{hg.headers.map((header) => (
<th key={header.id} className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
{flexRender(header.column.columnDef.header, header.getContext())}
</th>
))}
</tr>
))}
</thead>
<tbody>
{paddingTop > 0 && <tr style={{ height: paddingTop }} />}
{virtualRows.map((virtualRow) => {
const row = rows[virtualRow.index];
return (
<tr key={row.id} className="border-b border-gray-50 hover:bg-gray-50">
{row.getVisibleCells().map((cell) => (
<td key={cell.id} className="px-4 py-3 text-gray-700">
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
);
})}
{paddingBottom > 0 && <tr style={{ height: paddingBottom }} />}
</tbody>
</table>
</div>
);
}
๐ Senior Engineers. No Junior Handoffs. Ever.
You get the senior developer, not a project manager who relays your requirements to someone you never meet. Every Viprasol project has a senior lead from kickoff to launch.
- MVPs in 4โ8 weeks, full platforms in 3โ5 months
- Lighthouse 90+ performance scores standard
- Works across US, UK, AU timezones
- Free 30-min architecture review, no commitment
See Also
- React Suspense Patterns: Boundaries, Streaming SSR, and Error Boundaries
- React Query Server State: useQuery, Optimistic Updates, and Infinite Scroll
- TypeScript Utility Types: Mapped Types, Conditional Types, and Infer
- Next.js App Router: Server Components, Streaming, and Server Actions
- PostgreSQL Window Functions: ROW_NUMBER, RANK, and Running Totals
Working With Viprasol
Building a data-heavy admin dashboard or analytics UI that needs sortable, filterable, paginated tables with tens of thousands of rows? We implement TanStack Table with the right combination of client-side and server-side modes, row virtualization for performance, and TypeScript type safety โ so your tables stay fast at any scale.
About the Author
Viprasol Tech Team
Custom Software Development Specialists
The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.
Need a Modern Web Application?
From landing pages to complex SaaS platforms โ we build it all with Next.js and React.
Free consultation โข No commitment โข Response within 24 hours
Need a custom web application built?
We build React and Next.js web applications with Lighthouse โฅ90 scores, mobile-first design, and full source code ownership. Senior engineers only โ from architecture through deployment.