Data Tables with TanStack Table
Headless, sortable, filterable, and paginated tables using TanStack Table v8. Full styling control with no component library lock-in.
Principle
TanStack Table is a headless engine — it computes row models, manages sorting, filtering, and pagination state, but renders nothing. You own the markup. This separation means complete styling control without fighting a component library.
Wrap column definitions in useMemo with an empty dependency array. Column definitions are stable references — recreating them on every render causes unnecessary row model recalculations.
Rules
- check_circleColumns are stableWrap column definitions in useMemo(() => [...], []). Redefining them each render triggers unnecessary re-sorts and re-filters.
- check_circleOwn the render loopUse flexRender() for both headers and cells. Never manually extract cell values — let the column definition handle rendering.
- check_circleServer-side for large dataClient-side filtering and sorting works up to ~1,000 rows. Beyond that, move pagination and filtering to the server.
- check_circleGlobal vs column filtersUse globalFilter for quick full-text search. Use column-level filters for advanced filtering UI with per-field controls.
Pattern
import { useMemo, useState } from 'react'; import { useReactTable, getCoreRowModel, getSortedRowModel, getFilteredRowModel, getPaginationRowModel, flexRender, type ColumnDef, type SortingState, } from '@tanstack/react-table'; import type { User } from '@/types'; const columns: ColumnDef<User>[] = [ { accessorKey: 'name', header: 'Name' }, { accessorKey: 'email', header: 'Email' }, { accessorKey: 'role', header: 'Role' }, { accessorKey: 'status', header: 'Status' }, ]; export function UserTable({ data }: { data: User[] }) { const [sorting, setSorting] = useState<SortingState>([]); const [globalFilter, setGlobalFilter] = useState(''); const cols = useMemo(() => columns, []); const table = useReactTable({ data, columns: cols, state: { sorting, globalFilter }, onSortingChange: setSorting, onGlobalFilterChange: setGlobalFilter, getCoreRowModel: getCoreRowModel(), getSortedRowModel: getSortedRowModel(), getFilteredRowModel: getFilteredRowModel(), getPaginationRowModel: getPaginationRowModel(), }); // render table.getHeaderGroups() and table.getRowModel().rows }
Implementation
Version Compatibility
Requires React 19+ and the latest stable versions of all dependencies shown.
In Next.js, prefetch the initial page of data in a Server Component and pass it as initialData. The table renders immediately without a loading state.
import { UserTable } from '@/components/UserTable'; import { getUsers } from '@/services/users'; export default async function UsersPage() { const initialData = await getUsers({ page: 1, limit: 20 }); return <UserTable initialData={initialData} />; }
Live Demo
Name | Email | Role | Status | Created |
|---|---|---|---|---|
| Alice Johnson | alice@example.com | admin | active | Jan 15, 2024 |
| Bob Smith | bob@example.com | editor | active | Feb 10, 2024 |
| Carol Williams | carol@example.com | viewer | active | Feb 20, 2024 |
| David Brown | david@example.com | editor | inactive | Mar 5, 2024 |
| Eva Martinez | eva@example.com | admin | active | Mar 12, 2024 |
| Frank Garcia | frank@example.com | viewer | active | Mar 18, 2024 |
| Grace Lee | grace@example.com | editor | active | Apr 1, 2024 |
| Henry Wilson | henry@example.com | viewer | inactive | Apr 10, 2024 |
Page 1 of 3 (20 rows)