Complex Tables Without Pain
Data tables in React fail in two ways: either you build everything by hand and sink weeks into sorting, filtering and pagination, or you pick a high-level library and end up fighting its lock-in. TanStack Table v8 is the third path: a headless table engine that supplies all the logic and leaves the UI entirely in your hands.
Table of Contents
- 1. Understanding the Headless Principle
- 2. Basic Setup: useReactTable and Column Definitions
- 3. Sorting: Client-Side and Server-Side
- 4. Column Filtering and Global Search
- 5. Pagination: Client-Side and Server-Side
- 6. Column Pinning, Resizing and Hiding
- 7. Row Selection and Bulk Actions
- 8. Virtualization with TanStack Virtual
- 9. TanStack Table vs. AG Grid vs. MUI DataGrid
- 10. Summary
- 11. FAQ
1. Understanding the Headless Principle
The headless principle behind TanStack Table means the library supplies state, logic and calculations, but not a single HTML element, no CSS classes, no DOM. That sounds like more work at first, but it is exactly what sets it apart from libraries like AG Grid or MUI DataGrid: there is no lock-in to a particular styling system, no fighting predefined classes and no dependency on someone else's theme. Rendering TanStack Table v8 means you get data and methods, you build the <table> element yourself, and you have absolute control over semantics, styling and accessibility.
The core is the useReactTable() hook. It accepts data, column definitions and feature configurations and returns a table object. This object holds methods for retrieving rows, columns, header groups and every piece of state (sort direction, active filters, current page). Rendering happens through table.getHeaderGroups(), table.getRowModel().rows and flexRender(). This render call is the bridge between the table engine and React rendering: it accepts a cell renderer (function or component) and the cell props.
Another core distinction: TanStack Table v8 is framework-agnostic. The same core also runs with Vue, Solid and Angular. For React there is a thin adapter layer that ties the core state to React's state management. That means concepts you learn in TanStack Table carry over to other frameworks, a long-term advantage for teams running multiple frontend technologies.
2. Basic Setup: useReactTable and Column Definitions
Column definitions are the most important building block of a TanStack Table implementation. They are created in a type-safe way with createColumnHelper<DataType>(). The columnHelper.accessor() call takes the data access key (or a function for derived values) and an options object. The options describe everything about the column: header for the column name, cell for cell rendering, sortingFn for custom sorting and filterFn for custom filtering. TypeScript automatically infers which accessor keys are valid from the data type.
Column definitions should have a stable reference, either declared outside the component or wrapped in a useMemo. This matters: if the column definitions are recreated on every render, that triggers unnecessary re-renders of the entire table. getCoreRowModel() is the minimal row model factory every table needs. The additional feature row models (getSortedRowModel, getFilteredRowModel, getPaginationRowModel) are only added when the corresponding features are enabled.
// ProductTable.tsx: basic TanStack Table v8 setup with TypeScript
import {
useReactTable,
createColumnHelper,
getCoreRowModel,
getSortedRowModel,
flexRender,
type SortingState,
} from "@tanstack/react-table";
import { useState } from "react";
interface Product {
id: number;
name: string;
category: string;
price: number;
stock: number;
}
// Column helper provides type-safe accessor and display column creation
const columnHelper = createColumnHelper<Product>();
// Define columns outside component to maintain stable reference
const columns = [
columnHelper.accessor("id", {
header: "ID",
cell: (info) => <span className="font-mono text-slate-500">#{info.getValue()}</span>,
enableSorting: false,
}),
columnHelper.accessor("name", {
header: "Product name",
cell: (info) => <span className="font-semibold">{info.getValue()}</span>,
}),
columnHelper.accessor("price", {
header: "Price",
cell: (info) =>
new Intl.NumberFormat("de-DE", { style: "currency", currency: "EUR" })
.format(info.getValue()),
sortingFn: "basic",
}),
columnHelper.accessor("stock", {
header: "Stock",
cell: (info) => (
<span className={info.getValue() < 10 ? "text-red-600 font-bold" : ""}>
{info.getValue()}
</span>
),
}),
// Display column: no data accessor, custom content
columnHelper.display({
id: "actions",
header: "Actions",
cell: ({ row }) => (
<button onClick={() => console.log("edit", row.original.id)}>
Edit
</button>
),
}),
];
export function ProductTable({ data }: { data: Product[] }) {
const [sorting, setSorting] = useState<SortingState>([]);
const table = useReactTable({
data,
columns,
state: { sorting },
onSortingChange: setSorting,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
});
return (
<table className="w-full text-sm border-collapse">
<thead>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id} className="bg-slate-900 text-white">
{headerGroup.headers.map((header) => (
<th
key={header.id}
className="text-left p-4 font-semibold cursor-pointer select-none"
onClick={header.column.getToggleSortingHandler()}
>
{flexRender(header.column.columnDef.header, header.getContext())}
{/* Sort indicator */}
{{ asc: " ↑", desc: " ↓" }[header.column.getIsSorted() as string] ?? ""}
</th>
))}
</tr>
))}
</thead>
<tbody className="divide-y divide-slate-200">
{table.getRowModel().rows.map((row) => (
<tr key={row.id} className="hover:bg-slate-50">
{row.getVisibleCells().map((cell) => (
<td key={cell.id} className="p-4">
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))}
</tbody>
</table>
);
}
3. Sorting: Client-Side and Server-Side
Client-side sorting in TanStack Table is active in a few lines with getSortedRowModel(). The state lives in SortingState, an array of { id: string; desc: boolean } objects. Multi-sorting is enabled through enableMultiSort: true; users then sort by several columns at once with shift+click. A per-column sortingFn allows custom sorting algorithms: alphanumeric, case-insensitive, by date or by custom logic.
Server-side sorting requires manualSorting: true and no getSortedRowModel(). The sort state flows through TanStack Query as a query parameter to the backend: queryKey: ['products', { sorting }]. Every change to the sort state automatically triggers a new request. This pattern combines the strengths of both libraries: TanStack Table manages the UI state, TanStack Query handles data fetching. The result is a fully server-side sorted and paginated table without any custom state management logic.
4. Column Filtering and Global Search
Column filtering in TanStack Table v8 works on two levels: global filtering (globalFilter) searches all columns at once, column-level filtering (columnFilters) filters individual columns with their own logic. The global filter input is typically a search box driven by table.setGlobalFilter(value). Debouncing the input event prevents refiltering on every single keystroke.
The filterFn option per column can point to predefined functions ("includesString", "equalsString", "inNumberRange", "arrIncludes") or to custom filter logic. For custom filter UIs, category checkboxes, date pickers, range sliders, you render the filter element directly via column.getFilterValue() and column.setFilterValue(). All filter states can be stored in the search params of TanStack Router for URL synchronization, which makes the table view bookmarkable.
5. Pagination: Client-Side and Server-Side
Client-side pagination with getPaginationRowModel() works on the already filtered and sorted data set. The pagination state holds pageIndex and pageSize. table.nextPage(), table.previousPage(), table.setPageSize() and table.setPageIndex() control navigation. table.getCanNextPage() and table.getCanPreviousPage() control the disabled state of buttons. The total page count is available via table.getPageCount().
Server-side pagination requires manualPagination: true and specifying rowCount (the total number of records from the server). The pagination state is passed as a query parameter just like the sort state. Combining all three features, server-side sorting, filtering and pagination, produces a complete enterprise table where the client only holds the current page and the backend takes on all calculations. TanStack Table v8 consistently manages the UI state without replicating database logic in the frontend.
// ServerTable.tsx: server-side sorting, filtering and pagination
import { useReactTable, getCoreRowModel, type PaginationState } from "@tanstack/react-table";
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import { columns } from "./columns";
interface PagedResponse<T> {
data: T[];
rowCount: number;
}
export function ServerProductTable() {
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 25,
});
const [globalFilter, setGlobalFilter] = useState("");
// Fetch only the current page from the server
const { data, isLoading } = useQuery({
queryKey: ["products", "server-table", { pagination, globalFilter }],
queryFn: async (): Promise<PagedResponse<Product>> => {
const params = new URLSearchParams({
page: String(pagination.pageIndex + 1),
limit: String(pagination.pageSize),
...(globalFilter && { search: globalFilter }),
});
const res = await fetch(`/api/products?${params}`);
return res.json();
},
placeholderData: (prev) => prev, // keep old data while next page loads
});
const table = useReactTable({
data: data?.data ?? [],
columns,
rowCount: data?.rowCount, // needed for page count calculation
state: { pagination, globalFilter },
onPaginationChange: setPagination,
onGlobalFilterChange: setGlobalFilter,
getCoreRowModel: getCoreRowModel(),
manualPagination: true, // server handles pagination
manualFiltering: true, // server handles filtering
});
return (
<div>
<input
value={globalFilter}
onChange={(e) => { setGlobalFilter(e.target.value); setPagination((p) => ({ ...p, pageIndex: 0 })); }}
placeholder="Search products..."
className="mb-4 w-full border rounded-lg px-4 py-2"
/>
{/* Table rendering with table.getRowModel().rows */}
<div className="flex items-center gap-2 mt-4">
<button onClick={() => table.previousPage()} disabled={!table.getCanPreviousPage()}>Back</button>
<span>Page {table.getState().pagination.pageIndex + 1} of {table.getPageCount()}</span>
<button onClick={() => table.nextPage()} disabled={!table.getCanNextPage()}>Next</button>
</div>
</div>
);
}
6. Column Pinning, Resizing and Hiding
Column pinning lets you fix columns to the left or right edge of the table while the rest scrolls horizontally. This is essential for wide tables with an ID or name column that should always stay visible. In TanStack Table v8 this is controlled through column.pin('left') or column.pin('right'). For the CSS layout, pinned columns must be positioned with position: sticky and the correctly calculated left or right offset. The offset is calculated via column.getStart('left') and column.getAfter('right').
Column resizing is enabled with enableColumnResizing: true and a columnResizeMode ("onChange" for real-time resizing or "onEnd" for resizing only when the mouse is released). Every column header gets a resize-handle div that uses the column.getResizeHandler() function as its onMouseDown handler. Column hiding is the simplest of the three: column.getIsVisible() and column.toggleVisibility() control visibility. A dropdown with checkboxes for each column is quick to implement and considerably improves usability on wide tables.
7. Row Selection and Bulk Actions
Row selection in TanStack Table v8 is implemented via a display column with checkboxes. The state is RowSelectionState, an object with row IDs as keys and true as the value. table.getSelectedRowModel().rows returns all selected rows, table.getIsAllPageRowsSelected() drives the header checkbox. The "select all" behavior is configurable: either select across all pages or just the current one.
Bulk actions, deleting, exporting, changing status for multiple rows, build directly on top of the row selection state. An action panel appears when Object.keys(rowSelection).length > 0, showing the number of selected rows plus the available actions. The mutation uses table.getSelectedRowModel().rows.map(row => row.original) to extract the full records of the selected rows. After the mutation: table.resetRowSelection() resets the selection and queryClient.invalidateQueries() refreshes the cache.
8. Virtualization with TanStack Virtual
With tables holding thousands of rows, DOM performance becomes a problem: rendering every row as a <tr> element leads to long scroll jank and heavy memory use. TanStack Virtual (formerly react-virtual) solves this through virtualization: only the rows visible in the viewport are rendered, the rest is simulated with empty placeholder areas. Integration with TanStack Table is straightforward: you replace table.getRowModel().rows with the virtualized subset from rowVirtualizer.getVirtualItems().
The implementation requires a container with a fixed height and overflow-y: auto, a ref on that container for the virtualizer, and CSS for the placeholder areas (paddingTop and paddingBottom on the <tbody>). With this setup, a table with 100,000 rows scrolls just as smoothly as one with 100, because only 20 to 50 DOM elements ever exist. Scroll performance stays constant regardless of data volume, which is decisive for admin interfaces and data management tools.
9. TanStack Table vs. AG Grid vs. MUI DataGrid
The choice of table library depends heavily on the use case. A direct comparison shows where TanStack Table wins and where it has limits.
| Aspect | AG Grid Community | MUI DataGrid | TanStack Table v8 |
|---|---|---|---|
| Styling freedom | Limited (own theme API) | MUI-dependent | Completely free, own HTML |
| Setup effort | Low (out of the box) | Low (out of the box) | Medium (own rendering) |
| TypeScript | Partial | Partial | Full, generics |
| Bundle size | ~300 KB (Community) | ~150 KB + MUI | ~15 KB (headless core) |
| Virtualization | Built in | Per-feature | TanStack Virtual, separate |
AG Grid and MUI DataGrid are the right choice when the team needs a functional table quickly without much custom work and can live with the built-in styling. TanStack Table v8 is the right choice when complete styling control, minimal bundle size and maximum TypeScript type safety matter. The initial effort is higher, but the result is a table with zero external CSS dependencies.
Mironsoft
React data tables, TanStack Table and admin interfaces
Complex tables without styling lock-in?
We implement TanStack Table v8 in your React projects, with sorting, filtering, pagination, column pinning and TanStack Virtual for smooth scrolling through 100,000 rows.
Table audit
Analyze existing table implementations and define a migration path to TanStack Table v8
Custom table
Tailor-made table component with your design system, Tailwind and all the features you need
Server integration
Server-side sorting, filtering and pagination with TanStack Query and your API layer
10. Summary
TanStack Table v8 is the most powerful headless table framework for React. The headless principle gives absolute styling control without CSS lock-in. Typed column definitions with createColumnHelper<T>() turn tables into a fully TypeScript-verified part of the codebase. Client-side and server-side sorting, filtering and pagination can be toggled with simple feature flags. Column pinning, resizing and hiding are built in. Row selection with bulk actions follows a clear, predictable pattern. TanStack Virtual adds virtualization for very large data sets.
The biggest advantage over high-level libraries: with TanStack Table, the result is a fully custom table component that matches the project's design system and has no external CSS dependencies. No fighting foreign theme APIs, no inline overriding of library styles, no restrictions on accessibility. The initial effort pays off from the very first project that needs more than simple sorting and simple pagination.
TanStack Table v8, the essentials at a glance
Headless principle
No HTML, no CSS from the library. Full control over markup and styling. No lock-in to foreign themes or UI systems.
Column definitions
createColumnHelper<T>() for type-safe accessor and display columns. Define outside the component for a stable reference.
Server-side
manualSorting/Filtering/Pagination: true plus TanStack Query. State as a query key, automatic refetch on change.
Virtualization
TanStack Virtual for 100,000+ rows. Only visible DOM elements. Constant scroll performance regardless of data volume.