TanStack Table in React: Sorting, Filtering, Pagination in 100 Lines
Build a fully-featured React data table with TanStack Table v8 — sorting, filtering, and pagination — in under 100 lines of real, production-ready code.
Why TanStack Table (and Not Just a Component Library)
Most React devs hit the same wall around month 3 of a project: the pre-built table component from their UI kit doesn't do what they need. Maybe you want server-side sorting. Maybe you need virtual scrolling for 50,000 rows. Maybe the designer wants column reordering and the library's drag-and-drop is locked behind a $200/month enterprise tier. Sound familiar?
TanStack Table v8 (released 2022, still the gold standard in 2026) takes a completely different approach — it's a headless table library. No DOM, no CSS, no opinions about markup. You bring your own HTML and styles; TanStack Table gives you the logic engine. That's the whole deal. It hands you a table object stuffed with rows, columns, sort state, filter state, and pagination state, and you render whatever you want.
In practice, this makes it the right default for any team that cares about long-term design flexibility. Yes, there's more boilerplate on day one compared to dropping in a <DataGrid>. But you won't be ripping it out in six months when the design system changes. The trade-off is worth it.
Worth noting: if you're already using a design system from Empire UI you'll have your own card and surface primitives to wrap around TanStack Table rows — which is exactly the pattern this article uses.
Installation and the Mental Model
Install one package. That's it.
npm install @tanstack/react-tableThe mental model has three layers. First, you define column definitions — objects that describe each column: its header label, how to access the data, and any special cell renderers. Second, you call useReactTable() with your data and column defs, plus feature flags like getSortedRowModel and getFilteredRowModel. Third, you iterate over table.getHeaderGroups() and table.getRowModel().rows to render the actual HTML. That's the whole loop.
Quick aside: TanStack Table is fully TypeScript-native. The generic ColumnDef<TData> type means your IDE will scream if you mistype an accessor key — which is exactly the kind of feedback you want when you're mapping over 20 columns. Don't skip the generics, even in a prototype.
Building the Table: Under 100 Lines
Here's a complete, working example — sorting, column filtering, and pagination — in one component. I'm using Tailwind for the few styles needed, but you can swap any class names for your own.
import {
useReactTable,
getCoreRowModel,
getSortedRowModel,
getFilteredRowModel,
getPaginationRowModel,
flexRender,
ColumnDef,
SortingState,
} from '@tanstack/react-table';
import { useState, useMemo } from 'react';
type User = { id: number; name: string; email: string; role: string };
const data: User[] = [
{ id: 1, name: 'Alice Chen', email: 'alice@dev.io', role: 'Admin' },
{ id: 2, name: 'Bob Sharma', email: 'bob@dev.io', role: 'Editor' },
// ...more rows
];
export function UserTable() {
const [sorting, setSorting] = useState<SortingState>([]);
const [globalFilter, setGlobalFilter] = useState('');
const columns = useMemo<ColumnDef<User>[]>(
() => [
{ accessorKey: 'id', header: 'ID', size: 60 },
{ accessorKey: 'name', header: 'Name' },
{ accessorKey: 'email', header: 'Email' },
{ accessorKey: 'role', header: 'Role' },
],
[]
);
const table = useReactTable({
data,
columns,
state: { sorting, globalFilter },
onSortingChange: setSorting,
onGlobalFilterChange: setGlobalFilter,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
initialState: { pagination: { pageSize: 10 } },
});
return (
<div className="space-y-4">
<input
value={globalFilter}
onChange={e => setGlobalFilter(e.target.value)}
placeholder="Search all columns..."
className="w-full px-4 py-2 border rounded-lg"
/>
<table className="w-full text-sm">
<thead>
{table.getHeaderGroups().map(hg => (
<tr key={hg.id}>
{hg.headers.map(header => (
<th
key={header.id}
onClick={header.column.getToggleSortingHandler()}
className="px-3 py-2 text-left 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="border-t hover:bg-slate-50">
{row.getVisibleCells().map(cell => (
<td key={cell.id} className="px-3 py-2">
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))}
</tbody>
</table>
<div className="flex items-center gap-3">
<button onClick={() => table.previousPage()} disabled={!table.getCanPreviousPage()}>Prev</button>
<span>Page {table.getState().pagination.pageIndex + 1} of {table.getPageCount()}</span>
<button onClick={() => table.nextPage()} disabled={!table.getCanNextPage()}>Next</button>
</div>
</div>
);
}That's 94 lines including imports and fake data. The key call is useReactTable() — notice how you opt into features explicitly. Don't need sorting? Don't pass getSortedRowModel. The bundle only includes what you use, which matters when you're shipping to users on mobile networks.
The flexRender helper handles both string headers and custom React cell renderers. If you pass a function as cell, flexRender calls it with the cell context. If you pass a string, it renders the string. One API for both cases — clean.
Column-Level Filtering vs. Global Filter
The example above uses globalFilter — one search input that matches against every column value. It works great for user-facing search bars. But sometimes you want per-column filters: a date range picker on a 'Created' column, a dropdown on a 'Status' column. TanStack Table handles both patterns with the same underlying API.
For column-level filters, add columnFilters state alongside sorting and wire it to onColumnFiltersChange. Then, on each column def, you can access column.getFilterValue() and column.setFilterValue() to build whatever input control you want. A 'Status' column filter might look like this:
{
accessorKey: 'status',
header: 'Status',
filterFn: 'equals', // built-in: equals, includesString, inNumberRange, etc.
meta: { filterVariant: 'select' }, // custom meta to drive your UI
}Honestly, the filterFn API is one of the more underrated parts of TanStack Table. You get built-in functions like includesString, inNumberRange, and arrIncludes. Or pass a custom (row, columnId, filterValue) => boolean for anything exotic. No workarounds, no monkey-patching.
One more thing — if you're using server-side filtering (the data comes from an API call, not from the client-side array), set manualFiltering: true on useReactTable. This tells TanStack Table to skip the client-side filter pipeline and trust whatever data you pass in. Pair this with a useEffect that fires your API call whenever filter state changes, and you've got server-side filtering without any third-party abstractions.
Pagination: Client-Side vs. Server-Side
Client-side pagination — what the example above uses — is perfect when you've loaded all your data upfront (say, under 1,000 rows). The getPaginationRowModel() plugin slices the sorted, filtered rows into pages automatically. pageSize defaults to 10; change it with table.setPageSize(25) or set initialState.pagination.pageSize to whatever makes sense for your layout.
Server-side pagination is the other pattern and it's not much harder. Set manualPagination: true, pass pageCount from your API response, and update your data-fetch whenever table.getState().pagination changes:
const { pageIndex, pageSize } = table.getState().pagination;
useEffect(() => {
fetchUsers({ page: pageIndex, limit: pageSize }).then(setData);
}, [pageIndex, pageSize]);The pageIndex starts at 0, so if your API expects 1-based pages you'd pass pageIndex + 1. Obvious once you know it, annoying to debug when you don't. That 16px detail catches a lot of people off guard.
Look, you can also build a page-size select in about 8 lines. Just render a <select> that calls table.setPageSize(Number(e.target.value)). No special API, no plugin. The state propagates automatically and all the row models recompute.
Styling: Making It Look Good Without Fighting the Library
Headless means TanStack Table adds zero CSS. The flip side is you own 100% of the look. That's not a complaint — it means your table can match your design system exactly instead of fighting specificity battles with a vendor's stylesheet.
If you're using Tailwind, the approach in the code above (utility classes directly on <th> and <td>) is the fastest path. For a more polished result, pull your cell padding up to 12px or 16px, add ring-1 ring-slate-200 to the table wrapper, and use transition-colors duration-150 on the <tr> hover state. Small details, big difference.
For a genuinely premium aesthetic — glassmorphism panels around the table, gradient headers, animated sort indicators — check out what's available in the glassmorphism components section and the gradient generator. A backdrop-filter: blur(12px) wrapper around your <table> with a semi-transparent background reads as a completely different product than the same data in a plain white box.
That said, don't over-style the table itself. Dense data tables need breathing room and high text contrast, not layered visual effects. Save the frosted glass for the card wrapper, keep the cell text at text-slate-900 on white, and let the data speak.
Performance Tips for Large Datasets
For most apps under 5,000 rows, TanStack Table's default client-side pipeline is fast enough — React reconciliation is the bottleneck, not TanStack's logic. But if you're pushing beyond that, a few things make a real difference.
First, memoize your column definitions. They rarely change, and if you define them inline in the render function they'll trigger unnecessary recalculations on every state update. The useMemo(() => [...], []) wrapper in the example above isn't optional for larger tables.
Second, consider @tanstack/react-virtual alongside TanStack Table. The two libraries are designed to work together — virtual scrolling handles the DOM node count while TanStack Table handles the data logic. You can render 100,000 rows and keep your <tbody> at roughly 30 visible nodes at any time. The integration docs cover the exact pattern, but the short version is: replace table.getRowModel().rows.map(...) with a useVirtualizer loop over the same rows.
Third, for heavy server-side setups, debounce your filter state before it triggers API calls. A 300ms debounce on the global filter input alone can cut your API request volume by 80% during a typical user search session. useDeferredValue from React 18+ is another solid option if you want to keep the input snappy while the table updates slightly behind.
FAQ
Yes — TanStack Table is the rebrand of react-table. v8 (2022) was a full rewrite that made the library framework-agnostic, so the same core works in Vue, Svelte, and Solid too. The React adapter is @tanstack/react-table.
Absolutely. Set manualSorting, manualFiltering, and manualPagination to true, then fire your own API calls whenever the corresponding state changes. TanStack Table just manages the state; you control the data source.
Pass a cell function in the column definition: { accessorKey: 'status', cell: info => <Badge>{info.getValue()}</Badge> }. The info object gives you the cell value, row data, and table instance.
It's written in TypeScript and the generics are first-class. Use ColumnDef<YourRowType>[] for column definitions and you'll get full autocomplete on accessor keys and cell values.