tanstack-table
SKILL.md
TanStack Table v8 Best Practices
Core Concepts
TanStack Table is headless — it provides table logic and state, not UI. You bring your own markup and styling. This means full control over rendering with zero style conflicts.
Basic Setup
import { useReactTable, getCoreRowModel, flexRender } from "@tanstack/react-table";
function UsersTable({ 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>
);
}
Column Definitions
Define columns outside the component or memoize them to prevent infinite re-renders:
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",
cell: (info) => <Badge>{info.getValue()}</Badge>,
filterFn: "equalsString",
}),
columnHelper.accessor("createdAt", {
header: "Created",
cell: (info) => formatDate(info.getValue()),
sortingFn: "datetime",
}),
columnHelper.display({
id: "actions",
header: "Actions",
cell: ({ row }) => <RowActions user={row.original} />,
}),
];
- Use
columnHelperfor type-safe column definitions. accessorcolumns map to data fields with automatic type inference.displaycolumns are for UI-only elements (actions, checkboxes, expand toggles).
Data Stability
Data must have a stable reference. Passing a new array on every render causes infinite re-renders:
// Stable via useState
const [data, setData] = useState<User[]>([]);
// Stable via useMemo
const data = useMemo(() => transformRawData(rawData), [rawData]);
// Stable via TanStack Query (recommended)
const { data = [] } = useUsers();
Sorting
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
state: { sorting },
onSortingChange: setSorting,
});
Toggle sorting on headers:
<th
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>
Filtering
Column Filters
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
state: { columnFilters },
onColumnFiltersChange: setColumnFilters,
});
Global Filter
const table = useReactTable({
// ...
state: { globalFilter },
onGlobalFilterChange: setGlobalFilter,
globalFilterFn: "includesString",
});
Custom Filter Functions
const columns = [
columnHelper.accessor("price", {
filterFn: (row, columnId, filterValue) => {
const price = row.getValue<number>(columnId);
const { min, max } = filterValue as { min: number; max: number };
return price >= min && price <= max;
},
}),
];
Pagination
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
state: { pagination },
onPaginationChange: setPagination,
});
// Controls
table.getCanPreviousPage();
table.getCanNextPage();
table.previousPage();
table.nextPage();
table.setPageIndex(0);
table.setPageSize(20);
table.getPageCount();
Server-Side Operations
For large datasets, handle sorting/filtering/pagination on the server:
const table = useReactTable({
data: serverData,
columns,
getCoreRowModel: getCoreRowModel(),
manualPagination: true,
manualSorting: true,
manualFiltering: true,
pageCount: totalPages,
state: { pagination, sorting, columnFilters },
onPaginationChange: setPagination,
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
});
Pass the state to your API query (pairs well with TanStack Query):
const { data } = useQuery({
queryKey: ["users", pagination, sorting, columnFilters],
queryFn: () => fetchUsers({ pagination, sorting, filters: columnFilters }),
});
Row Selection
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
state: { rowSelection },
onRowSelectionChange: setRowSelection,
enableRowSelection: true,
// enableMultiRowSelection: false, // single-select mode
});
// Checkbox column
columnHelper.display({
id: "select",
header: ({ table }) => (
<Checkbox
checked={table.getIsAllRowsSelected()}
indeterminate={table.getIsSomeRowsSelected()}
onChange={table.getToggleAllRowsSelectedHandler()}
/>
),
cell: ({ row }) => <Checkbox checked={row.getIsSelected()} onChange={row.getToggleSelectedHandler()} />,
});
Column Visibility
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
state: { columnVisibility },
onColumnVisibilityChange: setColumnVisibility,
});
// Toggle UI
table.getAllLeafColumns().map((column) => (
<label key={column.id}>
<input type="checkbox" checked={column.getIsVisible()} onChange={column.getToggleVisibilityHandler()} />
{column.id}
</label>
));
Virtualization
For tables with thousands of rows, combine with @tanstack/react-virtual:
import { useVirtualizer } from "@tanstack/react-virtual";
const rowVirtualizer = useVirtualizer({
count: table.getRowModel().rows.length,
getScrollElement: () => tableContainerRef.current,
estimateSize: () => 48,
overscan: 10,
});
Render only the visible rows within a scrollable container.
Patterns
- Wrap in a custom hook — encapsulate table state and configuration in a
useUsersTable(data)hook. - Extract filter/pagination UI — build reusable
TablePagination,TableFilter, andColumnTogglecomponents that accept the table instance. - Type your row data — always define
TDataand pass it as a generic tocreateColumnHelper<TData>(). - Stable column definitions — define columns at module scope or memoize with
useMemo. Never define them inline in the render body.
Weekly Installs
2
Repository
grahamcrackers/skillsFirst Seen
14 days ago
Security Audits
Installed on
cline2
gemini-cli2
github-copilot2
codex2
kimi-cli2
cursor2