Build a type-safe base table with stable row identity and no dependency on prebuilt UI.
Table owns logic, not visual design
TanStack Table is headless: it produces column, header, row, cell, and interaction models, while the app still owns HTML, components, keyboard behavior, and CSS. Start with semantic table markup, then connect the same instance to your design system instead of searching for a nonexistent Table component.
Column definitions describe data and rendering
createColumnHelper constrains columns to the Issue type. Accessor columns should return primitive values suitable for sorting, filtering, or grouping; display-only buttons and links should not invent accessors. Function accessors need a stable id or string header.
import {
createColumnHelper, flexRender, getCoreRowModel, useReactTable,
} from '@tanstack/react-table'
type Issue = { id: string; title: string; status: 'open' | 'closed' }
const column = createColumnHelper<Issue>()
const columns = [
column.accessor('title', { header: 'Title' }),
column.accessor('status', { header: 'Status' }),
column.display({ id: 'actions', cell: ({ row }) => <a href={`/issues/${row.original.id}`}>View</a> }),
]
export function IssueTable({ issues }: { issues: Issue[] }) {
const table = useReactTable({
data: issues, columns, getRowId: (issue) => issue.id,
getCoreRowModel: getCoreRowModel(),
})
return <table><thead>{table.getHeaderGroups().map((group) => (
<tr key={group.id}>{group.headers.map((header) => <th key={header.id}>
{header.isPlaceholder ? null : 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>
}Row models form an explicit pipeline
getCoreRowModel turns raw data into base rows. Add sorting, filtering, or pagination row models only when those behaviors are needed. Return a domain id from getRowId so selection, expansion, and editing state do not follow array positions after reorder. Pass a stable empty array and render an explicit empty state.
Turn this lesson into a verifiable skill
Derive column values, rows, and render context from one Issue type, and explain why Table does not ship fixed UI components.
Add title, status, and priority columns to the issue list, plus a display-only “View details” column without an accessor.
After reordering data, row.id still equals the issue id; an empty array renders headers and empty state, and typecheck rejects an unknown accessor key.
- Returning an object from an accessor and expecting default sorting to understand it.
- Using array indexes as stable identities for server-backed rows.