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,sortIndex,toggleSort({ shiftKey })) 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 toformatValue,exportValue, then the key’s data path; a JSXaccessoris never compared. See sorting.i18nmaps locale tags to alternative data paths; the table’slocaleprop picks one (exact tag → primary subtag →key). The cell, client-side sort, row grouping, 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 |
formatValue → exportValue → data path |
Primitive extractor for the client-side sort. See sorting. |
formatValue |
(row) => string |
— | The cell as text, for screen readers, labels and copy. See below. |
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. |
minWidth / maxWidth / flex |
number |
— | Width bounds and a share of the leftover space. See column management. |
lockPosition / lockVisibility / lockWidth / lockPin |
boolean |
false |
Disable the matching Columns-menu and resize controls for this column. |
filter |
FilterType | Omit<FilterDef, "key" | "label"> |
— | Declare this column’s filter. See filtering. |
editable / editor |
boolean | (row) => boolean / CellEditor |
— | Inline editing for this column. See cell editing. |
colSpan / rowSpan |
number | (row) => number |
— | Span every cell of this column. See row spanning. |
groupShow |
"open" | "closed" | "always" |
"open" under a collapsible group |
When this leaf shows under a collapsed group. See column groups. |
responsivePriority |
number |
— | How readily the column is given up when the table is too narrow; priority 1 is kept longest. |
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 definition- the key’s data path
The first that yields a string, number, boolean or Date (read as its ISO day)
wins; nothing yields "". A React accessor or Cell is never consulted, so a
column whose rendered cell differs from its data path sets formatValue to say
what it shows.
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/react";
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,Cell,sortValue,exportValueandformatValueare 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. @adapttable/core exports the same helper typed as a neutral ColumnModel, with its spec type ComputedColumnSpec, for headless and non-React callers.
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.
Compose collapsibleColumnGroups() from @adapttable/<kit>/column-groups 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 sorts by its data path unless it setssortValue(orformatValue/exportValue); withoutsortValuea 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.