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 '@/shared/types/user'; const columns: ColumnDef<User>[] = [ { id: 'name', header: 'Name', // accessorFn combines two fields into one sortable, filterable column accessorFn: (row) => `${row.firstName} ${row.lastName}`, }, { accessorKey: 'email', header: 'Email' }, { accessorKey: 'age', header: 'Age' }, { accessorKey: 'gender', header: 'Gender' }, ]; 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(), }); return ( <div> <input value={globalFilter} onChange={(e) => setGlobalFilter(e.target.value)} placeholder="Filter all columns..." /> <table> <thead> {table.getHeaderGroups().map((headerGroup) => ( <tr key={headerGroup.id}> {headerGroup.headers.map((header) => ( <th key={header.id} onClick={header.column.getToggleSortingHandler()} > {flexRender(header.column.columnDef.header, header.getContext())} {{ asc: ' ↑', desc: ' ↓' }[header.column.getIsSorted() as string] ?? null} </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> <div> <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> </div> </div> ); }
Implementation
Version Compatibility
Requires React 19+ and the latest stable versions of all dependencies shown.
In Next.js, prefetch user data in a Server Component and hydrate it via HydrationBoundary. The table renders immediately with cached data while staying reactive to updates.
import { dehydrate, HydrationBoundary } from '@tanstack/react-query'; import { getQueryClient } from '@/lib/query-client'; import { queryKeys } from '@/lib/query-keys'; import { usersService } from '@/lib/services/users'; import { UserTable } from '@/features/users'; export default async function UsersPage() { const queryClient = getQueryClient(); await queryClient.prefetchQuery({ queryKey: queryKeys.users.all, queryFn: () => usersService.getAll({ limit: 100 }), }); return ( <HydrationBoundary state={dehydrate(queryClient)}> <UserTable /> </HydrationBoundary> ); }
View UserTable in starter
View the real implementation in react-principles-nextjs
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)