React table columns — ColumnDef & custom cells
▶ Try it live: open a Mantine starter in StackBlitz — this page’s feature is the starter’s whole columns array in src/App.tsx; edit it in the browser, no install. Other UI kits →
Columns are plain objects — declare a key and the table renders the value, derives the header, and wires sorting and filtering around it. Everything beyond the key is an opt-in refinement.
Example
Section titled “Example”import { type CellProps, type ColumnDef, DataTable } from "@adapttable/mantine"; // or mui, chakra, antd, radix, shadcn, unstyledimport { Badge } from "@mantine/core";
interface Person { id: string; name: string; nameAr: string; department: { name: string }; salary: number; status: "active" | "on-leave"; hiredAt: string;}
const people: Person[] = [ { id: "1", name: "Amira Hassan", nameAr: "أميرة حسن", department: { name: "Engineering" }, salary: 96000, status: "active", hiredAt: "2021-03-15", }, { id: "2", name: "Tom Becker", nameAr: "توم بيكر", department: { name: "Design" }, salary: 78000, status: "on-leave", hiredAt: "2022-11-01", }, { id: "3", name: "Lina Park", nameAr: "لينا بارك", department: { name: "Engineering" }, salary: 105000, status: "active", hiredAt: "2019-07-20", },];
// Define Cell components at module level so their identity is stable.function StatusCell({ row }: CellProps<Person>) { return ( <Badge color={row.status === "active" ? "green" : "yellow"}> {row.status} </Badge> );}
const columns: ColumnDef<Person>[] = [ // Bare key: auto header "Name"; i18n swaps the data path when locale="ar". { key: "name", i18n: { ar: "nameAr" }, sortable: true }, // Dot path reaches nested values; auto header "Department Name". { key: "department.name", header: "Department" }, // accessor formats; sortValue keeps the column sortable by the raw number. { key: "salary", header: "Salary (USD)", accessor: (r) => r.salary.toLocaleString(), sortValue: (r) => r.salary, sortable: true, align: "end", width: 140, }, // Cell: a full React component receiving { row, rowIndex }. { key: "status", Cell: StatusCell, mobileLabel: "Status" }, { key: "hiredAt", hideOnMobile: true, meta: { exportFormat: "date" } },];
export function People() { return ( <DataTable data={people} columns={columns} rowKey={(r) => r.id} locale="en" /> );}How it works
Section titled “How it works”- A bare
{ key }is a complete column: the key doubles as the row’s data path (dot paths reach nested values,"department.name"), and the header is auto-humanised (hiredAt→ “Hired At”). An explicitheaderalways wins, in any language. renderHeaderreplaces the caption only. The cell still owns sort, resize and the menu, and passes acontroller(label,sortDir,toggleSort) so a custom caption can stay wired.headerTooltipis a native title;headerActionssit after the caption.renderFooterreplaces one summary cell;tableFooteris a free slot under the table.- Cell content resolves
Cell→accessor→ the key’s data path.Cellis a React component receiving{ row, rowIndex };accessoris the lighter function form. Mini charts are a separate import — see sparkline columns. sortableopts a column into sorting; on frontend data the comparator readssortValue, falling back to the column’s accessor. See sorting.i18nmaps locale tags to alternative data paths; the table’slocaleprop picks one (exact tag → primary subtag →key). The cell, client-side sort, and the column’s filter all follow the resolved path — header text does not.renameableopts a leaf into user naming when the table also providesonColumnRename. This changes display text, mobile and export labels while the key and localized data paths stay fixed. See column management.hideOnMobile/hideOnDesktopdrop a column per layout;mobileLabeloverrides the label on mobile cards.keyis also the value sent to a backend assortBy, so keep it API-stable.
Options
Section titled “Options”| Prop | Type | Default | Description |
|---|---|---|---|
key |
string |
required | Unique id; data path for the cell value; the backend sortBy value. |
header |
ReactNode |
humanised from key |
Header content, pre-translated by the caller. |
renderHeader |
(ctx) => ReactNode |
— | Custom caption; receives a controller so sort/resize stay available. |
renderFooter |
(ctx) => ReactNode |
— | Custom summary-row cell. |
headerTooltip |
string |
— | Native tooltip on the caption. |
headerActions |
ReactNode |
— | Host controls after the caption. |
renameable |
boolean |
false |
Allow a host-persisted user display name from the Columns menu. |
accessor |
(row) => ReactNode |
read the key’s data path | Lightweight cell renderer. |
Cell |
ComponentType<CellProps<TRow>> |
— | Component per row, receives { row, rowIndex }; wins over accessor. |
sortable |
boolean |
false |
Enable sorting for this column. |
sortValue |
(row) => SortableValue |
the generated accessor value | Primitive extractor for the client-side sort. See sorting. |
exportValue |
(row) => unknown |
the display value | Value written to a CSV export when the file should carry something other than the formatted cell. |
align |
"start" | "center" | "end" |
"start" |
Text alignment within the cell. |
width |
number | string |
— | Width passed through to the rendered header/cell. |
mobileLabel |
string |
header (when a string) |
Label on mobile card layouts. |
hideOnMobile |
boolean |
false |
Hide the column entirely on mobile. |
hideOnDesktop |
boolean |
false |
Hide the column entirely on desktop. |
aggregatable |
false | true | { default?, operations } |
omitted | Whether a reader may aggregate this column, and with which operations. See row grouping. |
formatAggregate |
(value, context) => DisplayValue |
— | How a group aggregate of this column reads. Presentation only. |
group |
string |
— | Spanning header above adjacent columns sharing the name. See below. |
i18n |
Record<string, string> |
— | Per-locale data paths for the column’s value. |
meta |
Record<string, unknown> |
— | Free-form bag your own code can read back. |
locale |
string (table prop) |
— | Active locale tag ("ar", "ar-EG"); drives i18n path resolution. |
The cell as text
Section titled “The cell as text”accessor returns a ReactNode, which is right for rendering and useless
everywhere else: a status badge is a React element, an avatar cell is a
component, and neither is a word. Anything that needs the cell as a string —
a screen-reader announcement, an aria-label, a tooltip, a clipboard copy —
has nothing to read.
formatValue is that string:
{ key: "status", accessor: (row) => <Badge color={tone(row)}>{row.status}</Badge>, formatValue: (row) => row.status, // what a screen reader says}Text is always available, so formatValue only matters where it makes the text
accurate. columnText(column, row) resolves it in this order:
formatValue— the column stating its own textexportValue— the underlying value, minus the formattingsortValue— a primitive by definitionaccessor, when it happens to return a primitive- the key’s data path
with one deliberate restriction on that last step: a column that renders its
own cell never falls back to the data path. A column with
accessor: () => null renders an empty cell, and reading its path would
announce a value the user cannot see — worse than announcing nothing. Such a
column resolves to "", and giving it a formatValue is the fix.
Computed columns
Section titled “Computed columns”A total, a margin, a full name, days-until-due — columns whose value is derived
rather than stored. Writing the derivation into accessor works until the
column has to do anything else: sorting then compares the formatted string, so
"$1,240.00" sorts before "$90.00"; filtering has nothing to match; the
export carries the formatting; and the function runs again for every cell on
every render.
computed declares the derivation once and wires all four surfaces from it:
import { computed } from "@adapttable/core";
const columns = [ { key: "quantity" }, { key: "unitPrice" }, computed<Order, number>({ key: "total", header: "Total", deps: (row) => [row.quantity, row.unitPrice], value: (row) => row.quantity * row.unitPrice, format: (total) => money.format(total), column: { sortable: true, align: "end" }, }),];The screen shows $1,240.00; sorting, filtering and export all see 1240.
depsis required, and listing them is the whole contract. The value is recomputed when any dependency changes and reused when none do. A field the derivation reads but does not declare becomes a stale cell the moment the data changes underneath it.- The result is cached per row, in a
WeakMapkeyed by the row object — a row that leaves the page takes its cached value with it, so a long-lived table cannot grow a cache it never releases. formatis display only. Leave it out and primitives and dates render as text; any other value renders empty, since an object has no useful reading in a cell.columncarries everything else a column can be —sortable,align,width,filter,hideOnMobile.accessor,sortValueandexportValueare derived and cannot be set here, which is what keeps the four surfaces from disagreeing.
Define the columns at module level, or memoise them. The cache lives
inside the column computed returns, so rebuilding the column on every
render throws the cache away with it — values stay correct, nothing is
reused. It is the same rule Cell already asks for.
Rows must be objects, since the cache is keyed by row identity. The spec type is exported as ComputedColumnSpec for callers that build columns dynamically.
Grouped headers
Section titled “Grouped headers”Give adjacent columns the same group and they render under one spanning
header cell. A string is one level; a path stacks one header row per depth.
That shortcut is presentational — for collapse options and groups that stay
together through reorder, use a ColumnGroupDef parent with children.
See column groups.
const columns: ColumnDef<Person>[] = [ { key: "firstName", header: "First", group: "Name" }, { key: "lastName", header: "Last", group: "Name" }, { key: "q1", header: "Q1", group: ["Finance", "2026"] }, { key: "q2", header: "Q2", group: ["Finance", "2026"] }, { key: "hiredAt", header: "Hired" },];| Name | Finance | || | 2026 | || First | Last | Q1 | Q2 | Hired |Columns without a group sit under a blank spanning cell, so the header rows
always line up. The grouping is presentational and adjacency-based: the
span is computed from the columns as they are currently ordered, so dragging a
column out of the middle of a group splits it into two spans rather than
pretending the layout is something it is not. Reorder them back together and
the group closes up again.
Pass collapsibleColumnGroups and each real group header gains a toggle. What
a collapsed group shows is that group’s own options — an arrow stub by
default, a kept child via collapsedKey, or a cell via collapsedRender.
See column groups. Collapse state lives on
columnLayout.collapsedGroups and the URL (colGroupCollapse); group ids are
path.join("\u001f") so a label may contain /. On mobile the card layout has
no header row, but the same visible-column filter applies — cards hide the same
leaves a collapsed group hid on desktop.
- Define
Cellcomponents at module level (or memoise them) — an inline component re-mounts every render and defeats row memoisation. - Path-derived cells render primitives only; a non-primitive value at the path renders nothing. Use
accessororCellfor objects. - A column whose
accessorreturns JSX needssortValueto be sortable — without it the sort silently no-ops and a dev warning fires. mobileLabelonly falls back toheaderwhen the header is a string; with a JSX header, setmobileLabelexplicitly (it also names the column in the Columns menu).- Duplicate column keys trigger a development warning — keys must be unique within the table.
See it live in the demo.