web-ui-tanstack-table-d9ec88 — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited web-ui-tanstack-table-d9ec88 (Agent Skill) and scored it 100/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 0 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 0 flagged
Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
Quick Guide: TanStack Table v8 is table _logic_ — it computes rows, cells and header groups and renders nothing at all. You describe the data withcreateColumnHelper<T>(), opt into each feature by importing its row model, and render whatever markup you like from what the instance returns. Two facts cause most of the trouble:dataandcolumnsmust be stable references or the table recomputes forever, and themanual*flags are what tell it the server has already done the work.
Detailed Resources:
flexRenderaria-sortfilterFnmanual* flags and rowCountWhere the sorting, filtering and paging happen is the fork that changes every other answer.
getSortedRowModel, getFilteredRowModel, getPaginationRowModel — and the table does the work. Follow examples/core.md and the per-feature files.
manualSorting, manualFiltering and manualPaginationto true, supply rowCount, and import none of those row models — with a manual* flag set, the matching row model is ignored, so shipping both is dead weight that reads as a working feature. Follow examples/server-side.md.
rather than paging it. Follow examples/virtualization.md.
<critical_requirements>
Give `data` and `columns` stable references — useMemo, or a module-level constant. The table compares by identity, so a fresh array each render is a fresh set of columns each render, and the resulting update loop presents as a hung tab rather than as an error.
Type the column helper: `createColumnHelper<Row>()`. Every accessor key is then checked against the row type and each cell's getValue() is typed from the field it reads, which is what turns a renamed field into a compile error instead of a column of undefined.
Give `accessorFn` an explicit `id`. A string accessorKey supplies its own id; a function has no name to derive one from, and the column cannot be addressed for sorting, filtering or visibility without it.
Set `getRowId` wherever rows are selected or the data reloads. The default identity is the array index, so a sort, a filter or a refetch silently moves the selection to whichever rows now occupy those positions.
Import only the row models you use. Each is a separate entry point and the ones you leave out are tree-shaken, which is the whole reason the features are packaged this way.
Render through `flexRender`. header and cell may each be a string or a component, and flexRender is what handles both — reading columnDef.header directly works until the first column that supplies a function.
</critical_requirements>
Auto-detection: @tanstack/react-table, useReactTable, createColumnHelper, columnHelper.accessor, columnHelper.display, columnHelper.group, getCoreRowModel, getSortedRowModel, getFilteredRowModel, getPaginationRowModel, getExpandedRowModel, flexRender, ColumnDef, SortingState, ColumnFiltersState, PaginationState, RowSelectionState, VisibilityState, ExpandedState, ColumnPinningState, manualPagination, manualSorting, accessorKey, accessorFn, getRowId
Applies to:
Handled elsewhere:
<table> in the examplesis one way to render them, and the appearance of the result is not this skill's concern
by whatever means they arrived
and nothing about the widget that produces them
slice that fits is a separate capability, and examples/virtualization.md shows only the bridge between the two
<philosophy>
Headless means the library has no opinion you have to argue with. There is no theme to override, no markup to fight, and no component whose internals you need to reach into — the instance answers questions and you decide what to draw.
The cost of that is that nothing is on by default. A table that does not sort is not broken; it simply never imported getSortedRowModel. This trips people coming from a components library, where features are props to switch on, and it is worth holding onto: when a feature does nothing, the first question is whether its row model is present, and the second is whether a manual* flag has told the table not to bother.
The same design makes the library framework-agnostic — the core is plain TypeScript and the React adapter is thin — which is why the API reads as functions returning data rather than as hooks doing things.
</philosophy>
<decision_framework>
| Feature | Row model | Notes |
|---|---|---|
| Anything at all | getCoreRowModel | Always required |
| Sorting | getSortedRowModel | Omit when manualSorting is set |
| Column and global filtering | getFilteredRowModel | Omit when manualFiltering is set |
| Paging | getPaginationRowModel | Omit when manualPagination is set |
| Expandable rows, sub-rows | getExpandedRowModel | |
| Row grouping | getGroupedRowModel | |
| Distinct values for filter UIs | getFacetedRowModel | |
| Column pinning, column sizing | none | Both are part of core |
Reading a field directly? → columnHelper.accessor("email")
Reading a nested field? → columnHelper.accessor("address.city") (dot notation)
Deriving a value from the row? → columnHelper.accessor(fn, { id: "…" }) (id is required)
No data at all — buttons, a checkbox?
→ columnHelper.display({ id: "…" })
Heading a set of other columns? → columnHelper.group({ columns: [...] })The distinction that matters: an accessor's return value is what sorting and filtering operate on, so it stays a primitive. Anything visual belongs in cell.
</decision_framework>
<patterns>
The instance is built from data, columns and the row models you opted into. Both inputs are memoised; getRowId replaces index-based identity with something stable.
const columnHelper = createColumnHelper<User>();
const columns = useMemo(
() => [
columnHelper.accessor("firstName", { header: "First Name" }),
columnHelper.accessor((row) => `${row.firstName} ${row.lastName}`, {
id: "fullName", // required — a function has no key to derive one from
header: "Full Name",
}),
],
[],
);
const table = useReactTable({
data: useMemo(() => users, [users]),
columns,
getCoreRowModel: getCoreRowModel(),
getRowId: (row) => row.id,
});Full code: examples/core.md
Lift the state to make it persistable; the row model does the ordering.
const [sorting, setSorting] = useState<SortingState>([]);
const table = useReactTable({
/* … */
state: { sorting },
onSortingChange: setSorting,
getSortedRowModel: getSortedRowModel(),
});
columnHelper.accessor("createdAt", { sortingFn: "datetime" });Date objects need sortingFn: "datetime" — the default comparator treats them as strings, which orders by the day name.
Full code: examples/sorting.md
Column filters and the global filter are separate state with separate handlers, both served by one row model.
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [globalFilter, setGlobalFilter] = useState("");
const table = useReactTable({
/* … */
state: { columnFilters, globalFilter },
onColumnFiltersChange: setColumnFilters,
onGlobalFilterChange: setGlobalFilter,
getFilteredRowModel: getFilteredRowModel(),
});Multiple column filters intersect — a row must satisfy all of them. There is no built-in union; the global filter, or a custom filterFn, is how you get one.
Full code: examples/filtering.md
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 10,
});
const table = useReactTable({
/* … */
state: { pagination },
onPaginationChange: setPagination,
getPaginationRowModel: getPaginationRowModel(),
});pageIndex counts from zero. Most APIs count from one, so the conversion belongs at the request boundary and nowhere else.
Full code: examples/pagination.md
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const table = useReactTable({
/* … */
state: { rowSelection },
onRowSelectionChange: setRowSelection,
enableRowSelection: true,
getRowId: (row) => row.id, // the selection is keyed by this
});The selection is a map keyed by row id, so without getRowId it is keyed by array position — and sorting the table then reassigns every selection to a different record.
Full code: examples/selection.md
The manual* flags tell the table that the array it was handed is already sorted, filtered and paged. It then reports state changes and computes nothing.
const table = useReactTable({
data: apiData ?? [],
columns,
state: { pagination, sorting, columnFilters },
onPaginationChange: setPagination,
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
getCoreRowModel: getCoreRowModel(),
manualPagination: true,
manualSorting: true,
manualFiltering: true,
rowCount: totalFromApi, // it cannot count rows it never received
});Full code: examples/server-side.md
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({
email: false, // hidden until asked for
});
columnHelper.accessor("id", { enableHiding: false }); // never offered in the toggle UIHidden columns leave getVisibleCells() but stay in the table, so their filters and sorts still apply.
Full code: examples/column-visibility.md
const [expanded, setExpanded] = useState<ExpandedState>({});
const table = useReactTable({
/* … */
state: { expanded },
onExpandedChange: setExpanded,
getExpandedRowModel: getExpandedRowModel(),
getRowCanExpand: () => true, // or a predicate over row.original
});Expanded rows are interleaved into the row model rather than nested, so rendering a detail panel means checking row.getIsExpanded() and emitting an extra element yourself.
Full code: examples/expanding.md
Generics let one component serve every row type without losing inference at the call site.
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
data: TData[];
}
export function DataTable<TData, TValue>({
columns,
data,
}: DataTableProps<TData, TValue>) {
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
});
// render from table.getHeaderGroups() and table.getRowModel()
}Full code: examples/core.md
const [columnPinning, setColumnPinning] = useState<ColumnPinningState>({
left: ["id"],
right: ["actions"],
});Pinning reorders the columns and reports which side each one is on. It applies no positioning — the position: sticky, the offset and an opaque background are yours, and the background is what stops scrolling content showing through.
Full code: examples/column-pinning.md
const table = useReactTable({
/* … */
enableColumnResizing: true,
columnResizeMode: "onChange", // or "onEnd"
});"onChange" updates during the drag and re-renders every cell with it, so it needs the CSS-variable technique and a memoised body to hold a frame rate. "onEnd" commits on release and needs neither.
Full code: examples/column-resizing.md
</patterns>
<red_flags>
Breaks at runtime:
columns or data built inline — a new array identity every render, and the table updates in aloop until the tab stops responding
accessorFn without id — the column has no identifier, and the instance throws while buildingmanual* flag — the flag wins, so the row model is inertwhile looking like the feature is wired
manualPagination without rowCount or pageCount — the table cannot derive a page count from asingle page, so navigation stops at page one
getRowId — keyed by array index, so sorting or refetching moves the selectionto different records
values, and the column orders by nothing meaningful
columnDef.header or columnDef.cell directly instead of through flexRender — works forstring headers, renders a raw function for every other column
Surprising behaviour:
pageIndex is zero-based, where most APIs are one-basedautoResetPageIndex defaults to true, so any data change jumps back to page one — usually wrongin server-side mode, where new data arriving _is_ the page change
onMouseDown _and_ onTouchStart from getResizeHandler(), or it isinert on touch devices
getVisibleCells() and nothing upstream</red_flags>
~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.