Grid Cell Renderers
Cell Renderers
Per-column display customization through layered format and render hooks; engine-enforced memoization.
Each column decides for itself how its cells are displayed, through native
dateFormat / numberFormat configuration and three optional hooks on
PretableColumn<TRow>. The grid below reaches for one of them — render — on
every column it has:
A positions grid using all four presentation components together — PretableEntity for the symbol and name, PretableDelta for day P&L, PretableStatus for settlement, and PretableBadge for a risk or watch flag.
Those are PretableEntity for the symbol and name, PretableDelta for day P&L
(fed the formattedValue its column's numberFormat already produced),
PretableStatus for settlement, and PretableBadge for the risk/watch flag.
See Cell presentations for what each of those
four components does.
The three hooks are format (value → string), render (returns ReactNode),
and renderHeader (returns ReactNode). A format callback wins over native
date and number formatting, and the resolved string reaches render as
formattedValue — the handoff the Day P&L column above is built on. Every cell
is wrapped in React.memo with a custom equality check, so unchanged cells skip
re-render even when other parts of the grid update.
Pipeline
For every visible body cell, on every render:
- value extraction:
value = column.value ? column.value(row) : row[column.id]. - format: a column
formatcallback wins. Otherwise a compileddateFormathandles a canonicalYYYY-MM-DDstring, then a compilednumberFormathandles a compatiblenumberorbigint. Values not handled by those paths use the default, which joins arrays with", "and stringifies everything else. - render: if
column.renderis present, it returns the cell's ReactNode. Otherwise the grid-levelrenderBodyCellprop on<PretableSurface>applies. OtherwiseformattedValueis rendered as plain text.
format
Per-column string formatter. Use it for statuses and custom domain values that
don't need JSX. For canonical calendar dates, prefer compiled
dateFormat; for JavaScript number and bigint
values, prefer compiled numberFormat.
import { numberFormats, type PretableColumn } from "@pretable/react";
interface Order extends Record<string, unknown> {
id: string;
total: number;
placedAt: string | null;
status: "pending" | "shipped" | "delivered";
}
const columns: PretableColumn<Order>[] = [
{
id: "total",
header: "Total",
numberFormat: numberFormats.money({ currency: "USD" }),
},
{
id: "placedAt",
header: "Placed",
type: "date",
dateFormat: { dateStyle: "medium" },
},
{
id: "status",
header: "Status",
format: ({ value }) => {
switch (value) {
case "pending":
return "⏳ Pending";
case "shipped":
return "🚚 Shipped";
case "delivered":
return "✅ Delivered";
default:
return String(value);
}
},
},
];format is also used by Cmd+C copy serialization — set it once and both
display, clipboard, and CSV get consistent output. It outranks dateFormat and
numberFormat when a column supplies them. Native date and number formatting
are likewise shared without constructing a formatter inside the per-cell
callback.
render
Per-column ReactNode renderer for full UI control. Use it when you need a badge, a button, an icon, or any non-text content — @pretable/react ships four ready-made presentations for exactly this, all four visible in the grid above: PretableDelta, PretableStatus, PretableBadge and PretableEntity, covered in Cell presentations.
render receives { value, row, column, formattedValue, rowId, rowIndex, isFocused, isSelected, pinned }. The formattedValue is the resolved string
from format, native dateFormat, native numberFormat, or the default
formatter, so renderers do not need to repeat formatting. pinned is the column's live pin side ("left" | "right" | null) taken from the engine's column plan — use it instead of
column.pinned, which only reflects what the columns prop declared and goes
stale once a pin is set through controlled state, grid.setColumnPinned, or
drag-to-pin.
renderHeader
Per-column header renderer. Receives { column, label, sortDirection, isSorted, pinned }.
const columns: PretableColumn<Order>[] = [
{
id: "total",
header: "Total",
renderHeader: ({ label, isSorted }) => (
<span>
💰 {label} {isSorted && <span aria-hidden>•</span>}
</span>
),
},
];Memoization contract
Every cell is wrapped in React.memo with a custom equality check. The cell skips re-render when the following are all reference-equal between renders:
| Field | Source |
|---|---|
rowId | the row's id |
columnId | the column's id |
value | result of column.value(row) (or row[column.id]) |
formattedValue | result of format, native date/number formatting, or fallback |
isFocused | engine focus state for this cell |
isSelected | derived from selection ranges |
pinned | pin side from the engine's column plan |
renderRef | column.render reference (or null) |
fallbackRenderRef | grid-level renderBodyCell reference (or null) |
format and column.value are not in the memo key. They run unconditionally at the parent every render — but their cost is bounded by the cheapness contract:
column.valueis typically a property access. Nanoseconds.column.formatis typically a status lookup or a date/domain conversion. Native date and number formatter instances are compiled per configured column, not constructed inside the per-cell callback.
The memo bails out further down: if value didn't change AND format is pure, formattedValue is the same string, and the cell DOM is not re-rendered.
useMemo your column array
The most common perf cliff is inline column definitions in a parent that re-renders frequently. Each render creates a new column.render reference, busting the per-cell memo for every cell.
// ❌ inline — every parent render busts memo
function MyGrid({ orders }) {
return (
<PretableSurface
columns={[
{ id: "total", render: ({ value }) => <strong>{value}</strong> },
]}
rows={orders}
// ...
/>
);
}
// ✅ stable — memo bails out across parent re-renders
const columns: PretableColumn<Order>[] = [
{ id: "total", render: ({ value }) => <strong>{value as number}</strong> },
];
function MyGrid({ orders }) {
return <PretableSurface columns={columns} rows={orders} /* ... */ />;
}
// ✅ also stable — the surface compiles the column option for its locale
const moneyColumns: PretableColumn<Order>[] = [
{
id: "total",
numberFormat: numberFormats.money({ currency: "USD" }),
},
];
function MyGrid({ orders, locale }: { orders: Order[]; locale: string }) {
return (
<PretableSurface
columns={moneyColumns}
locale={locale}
rows={orders}
/* ... */
/>
);
}Header memoization is parallel: <MemoizedHeaderContent> keyed on (columnId, label, sortDirection, sortPriority, isSorted, width, isSortable, pinned, renderHeaderRef, fallbackRenderHeaderRef).
Interaction with grid-level renderBodyCell / renderHeaderCell
<PretableSurface> retains its existing grid-level renderBodyCell and renderHeaderCell props. Lookup precedence per cell:
column.render— if present, used.- Grid-level
renderBodyCell— if (1) absent, used. - Default —
formattedValueas plain text.
Same for headers (column.renderHeader → renderHeaderCell → default label + sort indicator).
<LabeledGridSurface> uses this exact precedence: column.render first, then
the wrapper's label/value renderer. Inside that wrapper, formatValue wins when
present and otherwise the wrapper prints formattedValue. Its formatValue
input includes raw value, row, and column alongside the already resolved
formattedValue, so it can decorate the display without losing either form.
Synthetic row-select column
The built-in row-selection checkbox column (id __pretable_row_select__, enabled via rowSelectionColumn={{ enabled: true }}) is non-overridable in v1. format, render, and renderHeader set on a column with that id are ignored — the synthetic column always renders the built-in three-state checkbox.
Telling it apart in the DOM
It renders as an ordinary cell and header — [data-pretable-cell] and [data-pretable-header-cell] both match it — but it is not one of your columns, and it is left-pinned by default, so it is the first match for either selector. A rule or a querySelector that means "a data column" and does not say so lands on the checkbox column instead.
What identifies it is not symmetric between the header and the body, which is the part that catches people:
data-pretable-column-id | Modifier | |
|---|---|---|
| Header | absent | data-pretable-row-select-header |
| Body cells | present, "__pretable_row_select__" | data-pretable-row-select-cell="true" |
So [data-pretable-column-id] narrows headers to real columns but does nothing for cells — every cell has an id, the checkbox column's included. Use the modifiers, which work on both sides:
/* Every data column, checkbox column excluded */
[data-pretable-cell]:not([data-pretable-row-select-cell]) {
font-variant-numeric: tabular-nums;
}
/* Just the checkbox column */
[data-pretable-cell][data-pretable-row-select-cell="true"] {
background: var(--pretable-bg-header);
}The same applies when you are reading the DOM rather than styling it — in an end-to-end test, say. document.querySelector("[data-pretable-header-cell]") returns the checkbox header, whose inline layout differs from a data column's, so a test sampling "a header cell" is not sampling one of yours.
Cell editing
Cell editing is supported through built-in typed editors and per-column
renderEditor overrides. Display format, dateFormat, and numberFormat
remain separate from formatEditValue, which only produces the string used to
seed an editor.
Editor value inputs, validate, and onCellEdit receive raw values rather than
formatted display strings. See Editing for parsing,
validation, custom editors, and the controlled commit lifecycle.
See also
- Selection — how
isSelectedis derived from cell-range selection. - Keyboard — how
isFocusedworks. - Clipboard —
formatis reused as the copy serializer. - Date formatting — canonical values and native presentation.
- API reference — full type signatures for
PretableColumn,PretableCellRenderInput,PretableHeaderRenderInput,PretableFormatInput.