Skip to main content

Chapter 23: Tables & Data Grids with TanStack Table

TanStack Table is a headless table library — it provides the logic for sorting, filtering, pagination, and selection while you control the rendering. This chapter covers setup, common patterns, and virtualization for large datasets.

Basic Table Setup

import {
useReactTable,
getCoreRowModel,
getSortedRowModel,
getFilteredRowModel,
getPaginationRowModel,
flexRender,
type ColumnDef,
type SortingState,
} from "@tanstack/react-table";

// Define columns with full type safety
const columns: ColumnDef<Project>[] = [
{
accessorKey: "name",
header: "Name",
cell: ({ row }) => (
<Link
to="/projects/$projectId"
params={{ projectId: row.original.id }}
className="font-medium hover:underline"
>
{row.getValue("name")}
</Link>
),
},
{
accessorKey: "status",
header: "Status",
cell: ({ row }) => <StatusBadge status={row.getValue("status")} />,
filterFn: "equals",
},
{
accessorKey: "priority",
header: "Priority",
cell: ({ row }) => <PriorityIndicator priority={row.getValue("priority")} />,
},
{
accessorKey: "taskCount",
header: () => <span className="text-right">Tasks</span>,
cell: ({ row }) => (
<span className="text-right font-mono">{row.getValue("taskCount")}</span>
),
},
{
accessorKey: "updatedAt",
header: "Last Updated",
cell: ({ row }) => formatRelativeDate(row.getValue("updatedAt")),
sortingFn: "datetime",
},
{
id: "actions",
cell: ({ row }) => <ProjectRowActions project={row.original} />,
},
];

function ProjectTable({ projects }: { projects: Project[] }) {
const [sorting, setSorting] = useState<SortingState>([]);
const [globalFilter, setGlobalFilter] = useState("");

const table = useReactTable({
data: projects,
columns,
state: { sorting, globalFilter },
onSortingChange: setSorting,
onGlobalFilterChange: setGlobalFilter,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
});

return (
<div>
{/* Search */}
<Input
value={globalFilter}
onChange={(e) => setGlobalFilter(e.target.value)}
placeholder="Search projects..."
className="mb-4 max-w-sm"
/>

{/* Table */}
<div className="rounded-md border">
<table className="w-full">
<thead>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id} className="border-b bg-muted/50">
{headerGroup.headers.map((header) => (
<th
key={header.id}
className="px-4 py-3 text-left text-sm font-medium text-muted-foreground"
onClick={header.column.getToggleSortingHandler()}
style={{ cursor: header.column.getCanSort() ? "pointer" : "default" }}
>
<div className="flex items-center gap-1">
{flexRender(header.column.columnDef.header, header.getContext())}
{header.column.getIsSorted() === "asc" && " ↑"}
{header.column.getIsSorted() === "desc" && " ↓"}
</div>
</th>
))}
</tr>
))}
</thead>
<tbody>
{table.getRowModel().rows.map((row) => (
<tr key={row.id} className="border-b transition-colors hover:bg-muted/50">
{row.getVisibleCells().map((cell) => (
<td key={cell.id} className="px-4 py-3 text-sm">
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>

{/* Pagination */}
<div className="flex items-center justify-between py-4">
<span className="text-sm text-muted-foreground">
{table.getFilteredRowModel().rows.length} projects
</span>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
Next
</Button>
</div>
</div>
</div>
);
}

Server-Side Pagination and Sorting

For large datasets, handle pagination and sorting on the server:

function ServerProjectTable() {
const { page, pageSize, sortBy, sortOrder, ...filters } = Route.useSearch();
const navigate = useNavigate();

const { data, isLoading } = useQuery(
projectListOptions({ ...filters, page, pageSize, sortBy, sortOrder })
);

const table = useReactTable({
data: data?.items ?? [],
columns,
pageCount: data?.totalPages ?? 0,
state: {
pagination: { pageIndex: page - 1, pageSize },
sorting: [{ id: sortBy, desc: sortOrder === "desc" }],
},
onPaginationChange: (updater) => {
const newPagination = typeof updater === "function"
? updater({ pageIndex: page - 1, pageSize })
: updater;
navigate({
search: (prev) => ({
...prev,
page: newPagination.pageIndex + 1,
pageSize: newPagination.pageSize,
}),
});
},
onSortingChange: (updater) => {
const newSorting = typeof updater === "function"
? updater([{ id: sortBy, desc: sortOrder === "desc" }])
: updater;
if (newSorting.length > 0) {
navigate({
search: (prev) => ({
...prev,
sortBy: newSorting[0].id,
sortOrder: newSorting[0].desc ? "desc" : "asc",
page: 1,
}),
});
}
},
manualPagination: true,
manualSorting: true,
getCoreRowModel: getCoreRowModel(),
});

// ... render table
}

Row Selection

const [rowSelection, setRowSelection] = useState<RowSelectionState>({});

const columns: ColumnDef<Project>[] = [
{
id: "select",
header: ({ table }) => (
<Checkbox
checked={table.getIsAllPageRowsSelected()}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label="Select all"
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label="Select row"
/>
),
enableSorting: false,
},
// ... other columns
];

const table = useReactTable({
// ...
state: { rowSelection },
onRowSelectionChange: setRowSelection,
enableRowSelection: true,
});

// Bulk actions
const selectedRows = table.getFilteredSelectedRowModel().rows;
{selectedRows.length > 0 && (
<div className="flex items-center gap-2">
<span className="text-sm">{selectedRows.length} selected</span>
<Button size="sm" variant="destructive" onClick={handleBulkDelete}>
Delete Selected
</Button>
</div>
)}

Virtualization for Large Datasets

For tables with thousands of rows, use TanStack Virtual:

import { useVirtualizer } from "@tanstack/react-virtual";

function VirtualizedTable({ data }: { data: Project[] }) {
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
});

const { rows } = table.getRowModel();
const parentRef = useRef<HTMLDivElement>(null);

const virtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 48, // Estimated row height in pixels
overscan: 10, // Render 10 extra rows above/below viewport
});

return (
<div ref={parentRef} className="h-[600px] overflow-auto rounded-md border">
<table className="w-full">
<thead className="sticky top-0 bg-background">
{/* ... header rows */}
</thead>
<tbody>
{/* Spacer for virtual rows above viewport */}
<tr><td style={{ height: `${virtualizer.getVirtualItems()[0]?.start ?? 0}px` }} /></tr>

{virtualizer.getVirtualItems().map((virtualRow) => {
const row = rows[virtualRow.index];
return (
<tr key={row.id} className="border-b">
{row.getVisibleCells().map((cell) => (
<td key={cell.id} className="px-4 py-3 text-sm">
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
);
})}

{/* Spacer for virtual rows below viewport */}
<tr>
<td style={{
height: `${virtualizer.getTotalSize() - (virtualizer.getVirtualItems().at(-1)?.end ?? 0)}px`
}} />
</tr>
</tbody>
</table>
</div>
);
}

Summary

  • TanStack Table is headless — you control all rendering
  • Typed columns with ColumnDef<T> ensure type-safe data access
  • Client-side sorting, filtering, and pagination for small datasets
  • Server-side pagination with manualPagination + URL search params
  • Row selection with built-in select-all support
  • Virtualization with TanStack Virtual for 10,000+ row tables