Data Table in React: Sort, Filter, Pagination With TanStack Table
Build a fully sortable, filterable, paginated data table in React using TanStack Table v8 — with real code, no hand-waving, and zero fluff.
Why TanStack Table and Not Something Pre-Built
TanStack Table v8 — released in 2022 and now battle-hardened — is a headless table library. No markup, no CSS, no opinions about how your <tr> should look. You own the DOM entirely. That's either annoying or liberating depending on what you're building, but for any real product dashboard it's almost always the right call.
Pre-built grid components like AG Grid Community or Material UI DataGrid are fine until they're not. You hit one edge case — a custom cell renderer that needs your own state, a column that conditionally renders a glassmorphism components tooltip — and suddenly you're fighting the library's internal assumptions. Headless sidesteps that entirely.
Honestly, the API surface of v8 feels big at first. It isn't. Once you understand that every feature (sort, filter, pagination, column visibility) is just a plugin you opt into via features, the mental model clicks. You add what you need and nothing else runs. Worth noting: the bundle for a basic sortable table is around 11 KB gzipped — smaller than most icon packages.
Install it with npm install @tanstack/react-table. That's the only dependency. No peer deps, no context providers to wrap your app in, no global store.
Setting Up Your First Table
Start with your data and column definitions. Column defs are where most of the configuration lives — accessors, headers, cell renderers, sort functions, filter functions. Here's a minimal setup for a user list:
import {
createColumnHelper,
flexRender,
getCoreRowModel,
useReactTable,
} from '@tanstack/react-table';
type User = {
id: number;
name: string;
email: string;
role: string;
joinedAt: string;
};
const columnHelper = createColumnHelper<User>();
const columns = [
columnHelper.accessor('name', {
header: 'Name',
cell: info => info.getValue(),
}),
columnHelper.accessor('email', {
header: 'Email',
}),
columnHelper.accessor('role', {
header: 'Role',
}),
columnHelper.accessor('joinedAt', {
header: 'Joined',
}),
];Then wire up the hook in your component. useReactTable returns everything you need to render — rows, headers, the works. You never touch the data directly after this point; the table instance handles all transformations.
export function UserTable({ data }: { data: User[] }) {
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
});
return (
<table>
<thead>
{table.getHeaderGroups().map(headerGroup => (
<tr key={headerGroup.id}>
{headerGroup.headers.map(header => (
<th key={header.id}>
{flexRender(header.column.columnDef.header, header.getContext())}
</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>
);
}That's a working table. No styling yet, but it renders rows and respects your column definitions. You can drop Tailwind classes onto those <th> and <td> elements directly — or any CSS-in-JS approach you already use.
Adding Sort — Click Header to Sort, Click Again to Reverse
Sorting in TanStack Table is two additions: getSortedRowModel from the library, and a sortingState in your component. The library does the actual sort; you manage which column is sorted and in which direction.
import {
// ...previous imports
getSortedRowModel,
SortingState,
} from '@tanstack/react-table';
import { useState } from 'react';
export function UserTable({ data }: { data: User[] }) {
const [sorting, setSorting] = useState<SortingState>([]);
const table = useReactTable({
data,
columns,
state: { sorting },
onSortingChange: setSorting,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
});
return (
<table>
<thead>
{table.getHeaderGroups().map(headerGroup => (
<tr key={headerGroup.id}>
{headerGroup.headers.map(header => (
<th
key={header.id}
onClick={header.column.getToggleSortingHandler()}
style={{ cursor: header.column.getCanSort() ? 'pointer' : 'default' }}
>
{flexRender(header.column.columnDef.header, header.getContext())}
{{
asc: ' ↑',
desc: ' ↓',
}[header.column.getIsSorted() as string] ?? null}
</th>
))}
</tr>
))}
</thead>
{/* ...tbody same as before */}
</table>
);
}That's it for basic sorting. Click a header once → ascending. Click again → descending. Click a third time → clears sort on that column. The getIsSorted() return value is 'asc', 'desc', or false, which makes the indicator trivial to render.
In practice, you'll want multi-sort eventually. Users sorting by role first then name within each role is a real workflow. Enable it by passing enableMultiSort: true to useReactTable. Now Shift+Click adds a secondary sort column. Zero extra code on your end beyond that one option.
Look, date columns sort alphabetically by default if your dates are ISO strings — which is actually correct since ISO 8601 lexicographic order matches chronological order. But if your dates are formatted like 'Sep 29, 2026', you need a custom sortingFn. Pass sortingFn: 'datetime' in the column def and make sure getValue() returns a parseable string or a Date.
Column Filtering With a Per-Column Input
Filtering follows the same pattern: add a state variable, import the right row model, wire up the option. The difference is that filter state is a ColumnFiltersState array — one entry per column that has an active filter.
import {
// ...previous imports
getFilteredRowModel,
ColumnFiltersState,
} from '@tanstack/react-table';
export function UserTable({ data }: { data: User[] }) {
const [sorting, setSorting] = useState<SortingState>([]);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const table = useReactTable({
data,
columns,
state: { sorting, columnFilters },
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
});
// ... render
}To render a filter input under each header, pull column.getFilterValue() and column.setFilterValue() directly on the column object. No need to reach back up into your own state — the library owns the filter value internally and re-renders when it changes.
// Inside your thead, below the sort header cell:
{header.column.getCanFilter() ? (
<input
value={(header.column.getFilterValue() ?? '') as string}
onChange={e => header.column.setFilterValue(e.target.value)}
placeholder={`Filter ${header.column.id}...`}
className="mt-1 w-full rounded border border-gray-200 px-2 py-1 text-sm"
/>
) : null}By default every column is filterable with a case-insensitive substring match on the string value. That covers 80% of use cases. For a role column where you want an exact enum match, set filterFn: 'equals' in the column def. For a date range, you'd use filterFn: 'inDateRange' — TanStack ships 10 built-in filter functions and you can write your own.
Quick aside: if you have 10,000+ rows and filter debouncing matters, wrap setFilterValue in a 200 ms debounce. The filter itself runs synchronously on every keystroke, which is fine up to a few thousand rows but starts feeling sluggish on mobile past that.
Pagination: Client-Side and Server-Side
Client-side pagination is the easy case. Add getPaginationRowModel, initialize a pagination state with { pageIndex: 0, pageSize: 20 }, and the library slices your filtered+sorted rows automatically.
import {
// ...previous imports
getPaginationRowModel,
PaginationState,
} from '@tanstack/react-table';
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 20,
});
const table = useReactTable({
data,
columns,
state: { sorting, columnFilters, pagination },
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
onPaginationChange: setPagination,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
});
// Pagination controls:
<div className="flex items-center gap-2 py-4">
<button onClick={() => table.previousPage()} disabled={!table.getCanPreviousPage()}>
Previous
</button>
<span>
Page {table.getState().pagination.pageIndex + 1} of {table.getPageCount()}
</span>
<button onClick={() => table.nextPage()} disabled={!table.getCanNextPage()}>
Next
</button>
<select
value={table.getState().pagination.pageSize}
onChange={e => table.setPageSize(Number(e.target.value))}
>
{[10, 20, 50, 100].map(size => (
<option key={size} value={size}>{size} per page</option>
))}
</select>
</div>Server-side pagination is where things get interesting. If you're fetching from an API and the server handles filtering and sorting, you pass manualPagination: true, manualSorting: true, and manualFiltering: true. Now the table skips its client-side transforms entirely. You're responsible for re-fetching when the state changes — typically with a useEffect or a query library like TanStack Query watching the state values.
// Server-side pattern with TanStack Query
const { data, isLoading } = useQuery({
queryKey: ['users', pagination, sorting, columnFilters],
queryFn: () => fetchUsers({ pagination, sorting, columnFilters }),
});
const table = useReactTable({
data: data?.rows ?? [],
columns,
rowCount: data?.totalCount, // tells the table total pages
state: { sorting, columnFilters, pagination },
manualPagination: true,
manualSorting: true,
manualFiltering: true,
// ...handlers
});The rowCount prop (added in TanStack Table v8.13) replaces the old pageCount option. Pass your server's total row count and the library calculates page count for you. No more off-by-one bugs when your dataset size changes mid-session.
Styling the Table to Match Your Design System
TanStack Table doesn't care what you put in those cell renderers. Swap Tailwind for CSS Modules, styled-components, or inline styles — works exactly the same. That said, if you're working in Tailwind, a few utility patterns save you from repetitive class lists.
For a dark dashboard aesthetic — the kind that pairs well with Empire UI's cyberpunk or aurora style tokens — a table that uses bg-gray-900 text-gray-100 with hover:bg-gray-800 row hover and a border-gray-700 grid looks sharp with minimal CSS. Stripe alternate rows with odd:bg-gray-900 even:bg-gray-800/50 directly on the <tr> element.
// Styled row example with Tailwind
{table.getRowModel().rows.map(row => (
<tr
key={row.id}
className="border-b border-gray-800 transition-colors hover:bg-gray-800/60"
>
{row.getVisibleCells().map(cell => (
<td
key={cell.id}
className="px-4 py-3 text-sm text-gray-300"
style={{ width: cell.column.getSize() }}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))}Column widths are a frequent pain point. Set size, minSize, and maxSize on each column def (values are in px). Then apply style={{ width: header.column.getSize() }} on your <th> and <td>. The library won't enforce this in the DOM on its own — you have to either use table-fixed on the <table> element in Tailwind or set width: 100% on the table and let the columns negotiate.
One more thing — if you want column resizing (drag to resize), add enableColumnResizing: true and columnResizeMode: 'onChange' to your table config. Then render a drag handle in each <th> using header.getResizeHandler(). It's a bit more wiring but the API is clean. You can browse ready-to-use table UI patterns in the Empire UI component library if you want a head start.
Global Search Across All Columns
Per-column filters are great for power users. A single global search box is what most people actually want on first load. TanStack Table handles this too, through getGlobalFilterFn and a globalFilter state value.
import { getFilteredRowModel } from '@tanstack/react-table';
const [globalFilter, setGlobalFilter] = useState('');
const table = useReactTable({
data,
columns,
state: { sorting, columnFilters, pagination, globalFilter },
onGlobalFilterChange: setGlobalFilter,
globalFilterFn: 'includesString', // built-in case-insensitive substring
// ...rest of config
});
// Render a search input anywhere in your UI:
<input
value={globalFilter}
onChange={e => setGlobalFilter(e.target.value)}
placeholder="Search all columns…"
className="w-64 rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-100 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-violet-500"
/>Global filter and column filter stack — a row has to pass both to appear. So a user could global-search for 'smith' and then column-filter role to 'admin' and see only admin Smiths. That behavior is usually exactly what you want and you get it for free.
Worth noting: global filter runs against every column's getValue() result by default. If you have a column with a formatted date string like 'Sep 29, 2026', that's what gets searched. If the raw value is a timestamp number, searching '2026' won't match. Either format your cell values consistently, or pass a custom getColumnCanGlobalFilter function that specifies which columns participate.
For production, debounce the global filter input at around 150–200 ms. With 5,000 client-side rows that's imperceptible. With 50,000 rows you'll feel the difference — and at that scale you should probably be on server-side filtering anyway.
FAQ
Yes. React Table was rebranded to TanStack Table in v8 (2022) to reflect its framework-agnostic architecture — it now supports Vue, Solid, and Svelte adapters alongside React. The React adapter is @tanstack/react-table. If you're on v7 or older, the API is significantly different and a migration is worth it.
Absolutely. Set manualPagination, manualSorting, and manualFiltering to true and the library skips all its client-side transforms. You watch the table state and re-fetch from your API when it changes. TanStack Query pairs with this perfectly since both libraries share the same author.
In your column definition, set enableSorting: false or enableColumnFilter: false. That column will be excluded from sort cycling and filter inputs won't appear for it. You can also do this globally on the table instance with enableSorting: false at the top level.
Realistically, TanStack Table handles around 10,000–20,000 rows comfortably in the browser before sort and filter operations start feeling slow. Past that, switch to server-side mode. Virtualization (rendering only visible rows) is a separate concern — pair it with @tanstack/react-virtual if you need to render 100k+ rows without pagination.