TL;DR: TanStack Table (formerly React Table) is a headless UI library for building high-performance data grids and tables. It handles sorting, filtering, pagination, row selection, column ordering, and column visibility — all with full TypeScript support and zero design opinion. This guide covers real-world SaaS patterns, including server-side pagination with D1, inline editing, export to CSV, and responsive columns for mobile.
Why TanStack Table?
Every SaaS application eventually needs a table — users, invoices, subscriptions, analytics. Building one from scratch seems simple until you need sorting, filtering, pagination, and column resizing. TanStack Table solves all of these without dictating your UI:
| Feature | TanStack Table | AG Grid | MUI Data Grid | Custom |
|---|---|---|---|---|
| Bundle size | ~18 KB | ~500 KB | ~120 KB | Varies |
| Headless | ✅ | ❌ | ❌ | ✅ |
| TypeScript | ✅ Full | Partial | Partial | Manual |
| Virtual scrolling | ✅ | ✅ | ✅ | Complex |
| Column resizing | ✅ | ✅ | ✅ | Complex |
| Row selection | ✅ | ✅ | ✅ | Manual |
| Export/CSV | Manual (easy) | ✅ | ✅ | Manual |
| Price | Free | $999+/yr | Free | Dev time |
| Custom rendering | Complete | Limited | Limited | Complete |
Basic Setup
Install the package with your preferred package manager:
npm install @tanstack/react-table
Minimal Data Table
Below is a complete subscription management table. It demonstrates the core TanStack Table concepts: type-safe columns via the column helper, built-in sorting, filtering, and pagination:
import {
useReactTable,
getCoreRowModel,
getSortedRowModel,
getFilteredRowModel,
getPaginationRowModel,
createColumnHelper,
flexRender,
} from "@tanstack/react-table"
// 1. Define your data type
type Subscription = {
id: string
customerEmail: string
plan: string
status: "active" | "canceled" | "past_due"
mrr: number
createdAt: string
}
// 2. Create column helper for type-safe columns
const columnHelper = createColumnHelper<Subscription>()
const columns = [
columnHelper.accessor("customerEmail", {
header: "Customer",
cell: (info) => info.getValue(),
}),
columnHelper.accessor("plan", {
header: "Plan",
cell: (info) => (
<span className="capitalize">{info.getValue()}</span>
),
}),
columnHelper.accessor("status", {
header: "Status",
cell: (info) => {
const status = info.getValue()
const colors = {
active: "text-green-600",
canceled: "text-gray-400",
past_due: "text-red-600",
}
return <span className={colors[status]}>{status}</span>
},
filterFn: "equalsString",
}),
columnHelper.accessor("mrr", {
header: "MRR",
cell: (info) => `$${info.getValue().toFixed(2)}`,
sortingFn: "basic",
}),
columnHelper.accessor("createdAt", {
header: "Created",
cell: (info) => new Date(info.getValue()).toLocaleDateString(),
}),
]
// 3. Create the table instance
function SubscriptionTable({ data }: { data: Subscription[] }) {
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
initialState: {
sorting: [{ id: "mrr", desc: true }],
pagination: { pageSize: 25 },
},
})
return (
<div>
<table>
<thead>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th key={header.id} onClick={header.column.getToggleSortingHandler()}>
{flexRender(header.column.columnDef.header, header.getContext())}
{{ asc: " ↑", desc: " ↓" }[header.column.getIsSorted() as string] ?? null}
</th>
))}
</tr>
))}
</thead>
<tbody>
{table.getRowModel().rows.map((row) => (
<tr key={row.id}>
{row.getVisibleCells().map((cell) => (
<td key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))}
</tbody>
</table>
{/* Pagination */}
<div className="flex items-center gap-4 mt-4">
<button onClick={() => table.setPageIndex(0)} disabled={!table.getCanPreviousPage()}>
{"<<"}
</button>
<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>
<button onClick={() => table.setPageIndex(table.getPageCount() - 1)} 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}>{size} per page</option>
))}
</select>
</div>
</div>
)
}
The SubscriptionTable component is fully functional with sorting, filtering, and pagination built in. The pagination UI includes page navigation buttons, a page indicator, and a configurable page size selector — all connected to the table instance without any additional state management.
Server-Side Pagination with Cloudflare D1
For SaaS applications with thousands of records, load data from the server instead of the client. Here's a server function that handles paginated, sorted queries against D1:
// server/subscriptions.ts
import { createServerFn } from "@tanstack/react-start"
interface PaginatedQuery {
page: number
pageSize: number
sortField: string
sortDir: "asc" | "desc"
filterStatus?: string
}
export const getSubscriptions = createServerFn({ method: "GET" }).handler(
async ({ data }: { data: PaginatedQuery }) => {
const offset = data.page * data.pageSize
// Build query with sorting and filtering
const whereClause = data.filterStatus
? "WHERE status = ?"
: ""
const countResult = await env.DB.prepare(
`SELECT COUNT(*) as total FROM subscriptions ${whereClause}`
)
.bind(...(data.filterStatus ? [data.filterStatus] : []))
.first() as { total: number }
const rows = await env.DB.prepare(
`SELECT id, customer_email, plan, status, mrr, created_at
FROM subscriptions ${whereClause}
ORDER BY ${data.sortField} ${data.sortDir}
LIMIT ? OFFSET ?`
)
.bind(...(data.filterStatus ? [data.filterStatus] : []), data.pageSize, offset)
.all()
return {
data: rows.results,
total: countResult.total,
page: data.page,
pageSize: data.pageSize,
}
}
)
The server function runs entirely on Cloudflare Workers, executing SQL directly against D1. It returns both the paginated rows and the total count, allowing the client to render accurate pagination controls.
// components/SubscriptionTable.tsx — server-side pagination
import { useQuery } from "@tanstack/react-query"
import { useReactTable, getCoreRowModel } from "@tanstack/react-table"
import { getSubscriptions } from "../server/subscriptions"
function ServerSideTable() {
const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 25 })
const [sorting, setSorting] = useState<SortingState>([])
const query = useQuery({
queryKey: ["subscriptions", pagination, sorting],
queryFn: () =>
getSubscriptions({
data: {
page: pagination.pageIndex,
pageSize: pagination.pageSize,
sortField: sorting[0]?.id ?? "created_at",
sortDir: sorting[0]?.desc ? "desc" : "asc",
},
}),
})
const table = useReactTable({
data: query.data?.data ?? [],
columns,
pageCount: Math.ceil((query.data?.total ?? 0) / pagination.pageSize),
state: { pagination, sorting },
onPaginationChange: setPagination,
onSortingChange: setSorting,
getCoreRowModel: getCoreRowModel(),
manualPagination: true,
manualSorting: true,
})
// ... render
}
After integrating with useQuery, the table receives data from the server. The manualPagination and manualSorting flags tell TanStack Table to rely entirely on server responses for pagination and sorting state.
Row Selection and Bulk Actions
TanStack Table supports built-in row selection with checkboxes. Here's how to enable bulk actions like canceling or exporting selected subscriptions:
function BulkActionsTable() {
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
state: { rowSelection },
getRowId: (row) => row.id,
})
const selectedIds = Object.keys(rowSelection)
return (
<div>
{selectedIds.length > 0 && (
<div className="bg-blue-50 p-3 rounded mb-4 flex gap-2 items-center">
<span>{selectedIds.length} selected</span>
<button onClick={() => bulkCancel(selectedIds)} className="...">
Cancel Subscriptions
</button>
<button onClick={() => bulkExport(selectedIds)} className="...">
Export Selected
</button>
</div>
)}
{/* Table rendering */}
</div>
)
}
When rows are selected, a floating action bar appears with bulk operation buttons. The rowSelection state is managed internally by TanStack Table and provides the selected row IDs for your mutation functions.
Column Visibility Toggle
Let users customize their view by toggling column visibility. Here's a dropdown component that renders a checkbox for each hideable column:
function ColumnToggle({ table }: { table: Table<Subscription> }) {
const columns = table.getAllLeafColumns()
return (
<div className="dropdown">
<button>Columns</button>
<div className="dropdown-content">
{columns
.filter((col) => col.getCanHide())
.map((col) => (
<label key={col.id} className="flex items-center gap-2">
<input
type="checkbox"
checked={col.getIsVisible()}
onChange={col.getToggleVisibilityHandler()}
/>
{col.columnDef.header as string}
</label>
))}
</div>
</div>
)
}
The column toggle component filters out columns that can't be hidden (getCanHide()) and allows users to show or hide individual columns. This preference can be persisted to localStorage for return visits.
Export to CSV
For data analysis workflows, provide a CSV export function. This utility converts an array of objects to a downloadable CSV file:
function exportToCsv<T extends Record<string, unknown>>(
rows: T[],
filename: string
) {
const headers = Object.keys(rows[0])
const csvContent = [
headers.join(","),
...rows.map((row) =>
headers
.map((h) => JSON.stringify(row[h] ?? ""))
.join(",")
),
].join("
")
const blob = new Blob([csvContent], { type: "text/csv" })
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
a.download = `${filename}.csv`
a.click()
URL.revokeObjectURL(url)
}
The function extracts headers from the first row's keys, maps each row to comma-separated values with JSON stringification for safe escaping, and triggers a browser download via a dynamically created anchor element.
Responsive Columns
For mobile-friendly tables, hide less important columns using a meta field. Here's how to mark columns for responsive hiding:
const columns = [
columnHelper.accessor("customerEmail", { header: "Customer" }),
columnHelper.accessor("plan", {
header: "Plan",
meta: { hideOnMobile: true },
}),
columnHelper.accessor("status", { header: "Status" }),
columnHelper.accessor("mrr", {
header: "MRR",
meta: { hideOnMobile: true },
}),
]
// Use CSS to hide columns with `hideOnMobile`
// @media (max-width: 768px) {
// th[data-hide-on-mobile="true"],
// td[data-hide-on-mobile="true"] {
// display: none;
// }
// }
The meta: { hideOnMobile: true } annotation is a custom field that you can use in CSS/media queries. Combined with a data-attribute on cells, this lets you hide non-critical columns on small screens without losing TanStack Table's column management.
Performance: Virtual Scrolling
For tables with 10,000+ rows, combine TanStack Table with TanStack Virtual. The virtualizer controls which rows are rendered while the table manages column logic:
import { useReactTable, getCoreRowModel } from "@tanstack/react-table"
import { useVirtualizer } from "@tanstack/react-virtual"
function VirtualizedTable({ data, columns }: Props) {
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
})
const { rows } = table.getRowModel()
const parentRef = useRef<HTMLDivElement>(null)
const virtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 48, // Row height in pixels
overscan: 10,
})
return (
<div ref={parentRef} style={{ height: "600px", overflow: "auto" }}>
<div style={{ height: `${virtualizer.getTotalSize()}px` }}>
<table>
<thead>{/* sticky header */}</thead>
<tbody>
{virtualizer.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>
</div>
)
}
The useVirtualizer only renders ~30 rows regardless of the total dataset size. The table provides the row data and column definitions, while the virtualizer handles which rows to render based on scroll position. This combination handles 100,000+ row datasets with no performance degradation.
Production Checklist
- Server-side pagination for datasets over 500 rows
- Column sorting with visual indicators (asc/desc)
- Column visibility toggle with persisted preference
- Row selection for bulk actions (cancel, export, delete)
- Responsive columns that adapt to mobile viewports
- Loading skeleton states during data fetch
- Empty state when no data matches filters
- Error state with retry button
- Export to CSV for data analysis
- Keyboard navigation (arrow keys, tab, enter)
- Accessible labels for screen readers
Conclusion
TanStack Table is the foundation of data display in modern SaaS applications. Its headless architecture means you get powerful features — sorting, filtering, pagination, selection — without fighting a pre-designed UI. Combined with TanStack Virtual for performance and TanStack Query for data fetching, it handles everything from a 10-row admin panel to a 100,000-row analytics dashboard.
See these patterns in production at tanstackship.com.
For more on building data-rich interfaces, explore the Virtual Scrolling Guide for performance optimization, the Form Complete Guide for type-safe form validation, the Query + Server Functions Integration Guide for data fetching patterns, and the TanStack Ecosystem Overview 2026 for the full framework landscape.