TanStack Table is the headless UI library that gives full-stack developers complete control over data grids without forcing a specific markup or styling approach. This guide covers setup, core concepts, common mistakes, and production patterns for building fast, flexible tables in React, Vue, or Svelte.
If you've built tables in React, you know the pain: state management for sorting, filtering, pagination, and column visibility quickly becomes a mess of useState calls and prop drilling. TanStack Table solves this by centralizing all table logic into a single headless engine. I've used it across dozens of production apps, and it's replaced every other table library I previously reached for.
Why TanStack Table Matters (and When to Skip It)
TanStack Table (formerly React Table) is headless — it provides the logic and state management, but you own the rendering. This is its superpower. You can render a <table>, a CSS grid, or even virtualized divs, and the library doesn't care. The API is framework-agnostic, so the same mental model applies whether you're in React, Vue, Svelte, or Solid.
Skip it if you need a zero-config, opinionated table with built-in styling and advanced features out of the box. Libraries like AG Grid or Material-UI Table give you more for free. But if you need custom behavior, complex column logic, or pixel-perfect design control, TanStack Table is the better foundation.
Getting Started with TanStack Table
Install the React adapter and core library:
npm install @tanstack/react-table
Here's a minimal working setup with TypeScript:
import { useReactTable, getCoreRowModel, ColumnDef, flexRender } from '@tanstack/react-table';
type User = {
id: number;
name: string;
email: string;
};
const columns: ColumnDef<User>[] = [
{ accessorKey: 'id', header: 'ID' },
{ accessorKey: 'name', header: 'Name' },
{ accessorKey: 'email', header: 'Email' },
];
const data: User[] = [
{ id: 1, name: 'Alice', email: 'alice@example.com' },
{ id: 2, name: 'Bob', email: 'bob@example.com' },
];
function UsersTable() {
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 it. You now have a fully controlled table with zero framework-specific markup.
Core TanStack Table Concepts Every Developer Should Know
Column Definitions
Columns are plain objects that describe how to access, display, and format data. The accessorKey maps directly to a property, while accessorFn gives you computed values:
const columns: ColumnDef<User>[] = [
{
accessorFn: row => `${row.firstName} ${row.lastName}`,
id: 'fullName',
header: 'Full Name',
cell: info => <strong>{info.getValue() as string}</strong>,
},
{
accessorKey: 'status',
header: 'Status',
cell: info => (
<span className={info.getValue() === 'active' ? 'badge-green' : 'badge-red'}>
{info.getValue() as string}
</span>
),
},
];
The cell function receives a context object with getValue(), row, and table — giving you full access to the row's data for custom rendering.
Sorting and Filtering
Enable sorting with one line, then wire up handlers:
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
state: { sorting },
onSortingChange: setSorting,
});
// In your header:
<th onClick={header.column.getToggleSortingHandler()}>
{flexRender(header.column.columnDef.header, header.getContext())}
{header.column.getIsSorted() === 'asc' ? ' ↑' : header.column.getIsSorted() === 'desc' ? ' ↓' : ''}
</th>
Row Selection
For bulk actions, row selection is built in:
const [rowSelection, setRowSelection] = useState({});
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
state: { rowSelection },
onRowSelectionChange: setRowSelection,
});
// Row rendering:
<tr className={row.getIsSelected() ? 'selected' : ''}>
<td>
<input
type="checkbox"
checked={row.getIsSelected()}
onChange={row.getToggleSelectedHandler()}
/>
</td>
</tr>
Common TanStack Table Mistakes and How to Fix Them
Mistake 1: Not memoizing data and columns. Passing inline arrays causes the table to re-render on every parent render. Fix: wrap data in useMemo and define columns outside the component or in a useMemo.
const columns = useMemo<ColumnDef<User>[]>(() => [...], []);
const data = useMemo(() => fetchUsers(), [users]);
Mistake 2: Ignoring getRowId. By default, TanStack Table uses row index as the ID. This breaks selection and expansion when data reorders. Fix: provide a stable ID:
useReactTable({
data,
columns,
getRowId: row => row.id.toString(), // stable identifier
});
Mistake 3: Rendering everything without virtualization. For tables with 1000+ rows, the DOM becomes a bottleneck. TanStack Table integrates with TanStack Virtual:
const virtualizer = useVirtualizer({
count: table.getRowModel().rows.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 35,
});
When Should You Use TanStack Table?
Use TanStack Table when you need fine-grained control over table behavior and rendering, especially in data-heavy applications like admin dashboards, analytics tools, or internal operations platforms. It's ideal when you need custom sorting, filtering, pagination, or column visibility without fighting a pre-built component's opinionated API. It's also the right choice when you're already using a component library like shadcn/ui or Radix and just need the logic layer. If you're building a simple display table with 50 rows and no interactions, a plain <table> with CSS is simpler — don't over-engineer.
TanStack Table in Production
Tip 1: Use column pinning for wide tables. Pin the first column (like an ID or name) so it stays visible during horizontal scroll:
{ header.column.getIsPinned() ? '📌' : '' }
Tip 2: Debounce server-side filtering. Don't fire an API call on every keystroke. Use a useDebounce hook to wait 300ms before fetching. This prevents hammering your backend.
Tip 3: Keep table state in the URL. For shareable, bookmarked table states (sort, filter, page), serialize the table state into query parameters. It's a small effort that pays off massively for user experience. I've built this pattern into several production apps and it's always worth it.
Tip 4: Extract reusable table components. Build a generic <DataTable> wrapper around TanStack Table that accepts columns and data as props. You'll reuse it across every page — I've got one in my production codebase that powers five different admin screens. You can see similar patterns in the projects I've documented at suhailroushan.com.
The one thing to remember: TanStack Table gives you the engine, not the car. Build your own styled, memoized, virtualized table component once, and you'll never reach for another table library again.