Browse documentation
Lesson 18 · Headless table

Model issue data as a headless table

Connect data to semantic table markup with typed column definitions, a core row model, and flexRender while retaining full styling control.

TanStack TableFoundation24 minREV 01Markdown .md
After this lesson

Build a type-safe base table with stable row identity and no dependency on prebuilt UI.

01

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.

02

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.

src/features/issues/IssueTable.tsxtsx
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>
}
03

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.

PRACTICE

Turn this lesson into a verifiable skill

Completion checkpoint

Derive column values, rows, and render context from one Issue type, and explain why Table does not ship fixed UI components.

Exercise

Add title, status, and priority columns to the issue list, plus a display-only “View details” column without an accessor.

Verification

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.

Common pitfalls
  • Returning an object from an accessor and expecting default sorting to understand it.
  • Using array indexes as stable identities for server-backed rows.
SOURCE REFERENCES · CHECKED 2026-08-03https://tanstack.com/table/latest/docs/introductionhttps://tanstack.com/table/latest/docs/guide/column-defshttps://tanstack.com/table/latest/docs/guide/rows
TanStack Atlas

Original bilingual knowledge · verified against primary sources

Friend linksGitHub