Skip to content

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.

import { type CellProps, type ColumnDef, DataTable } from "@adapttable/mantine"; // or mui, chakra, antd, radix, shadcn, unstyled
import { 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"
/>
);
}
  • 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 explicit header always wins, in any language.
  • renderHeader replaces the caption only. The cell still owns sort, resize and the menu, and passes a controller (label, sortDir, toggleSort) so a custom caption can stay wired. headerTooltip is a native title; headerActions sit after the caption. renderFooter replaces one summary cell; tableFooter is a free slot under the table.
  • Cell content resolves Cellaccessor → the key’s data path. Cell is a React component receiving { row, rowIndex }; accessor is the lighter function form. Mini charts are a separate import — see sparkline columns.
  • sortable opts a column into sorting; on frontend data the comparator reads sortValue, falling back to the column’s accessor. See sorting.
  • i18n maps locale tags to alternative data paths; the table’s locale prop 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.
  • renameable opts a leaf into user naming when the table also provides onColumnRename. This changes display text, mobile and export labels while the key and localized data paths stay fixed. See column management.
  • hideOnMobile / hideOnDesktop drop a column per layout; mobileLabel overrides the label on mobile cards.
  • key is also the value sent to a backend as sortBy, so keep it API-stable.
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.

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:

  1. formatValue — the column stating its own text
  2. exportValue — the underlying value, minus the formatting
  3. sortValue — a primitive by definition
  4. accessor, when it happens to return a primitive
  5. 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.

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.

  • deps is 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 WeakMap keyed 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.
  • format is 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.
  • column carries everything else a column can be — sortable, align, width, filter, hideOnMobile. accessor, sortValue and exportValue are 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.

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 Cell components 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 accessor or Cell for objects.
  • A column whose accessor returns JSX needs sortValue to be sortable — without it the sort silently no-ops and a dev warning fires.
  • mobileLabel only falls back to header when the header is a string; with a JSX header, set mobileLabel explicitly (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.