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:

Cell presentations

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.

.md

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:

  1. value extraction: value = column.value ? column.value(row) : row[column.id].
  2. format: a column format callback wins. Otherwise a compiled dateFormat handles a canonical YYYY-MM-DD string, then a compiled numberFormat handles a compatible number or bigint. Values not handled by those paths use the default, which joins arrays with ", " and stringifies everything else.
  3. render: if column.render is present, it returns the cell's ReactNode. Otherwise the grid-level renderBodyCell prop on <PretableSurface> applies. Otherwise formattedValue is 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.

tsx
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 }.

tsx
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:

FieldSource
rowIdthe row's id
columnIdthe column's id
valueresult of column.value(row) (or row[column.id])
formattedValueresult of format, native date/number formatting, or fallback
isFocusedengine focus state for this cell
isSelectedderived from selection ranges
pinnedpin side from the engine's column plan
renderRefcolumn.render reference (or null)
fallbackRenderRefgrid-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.value is typically a property access. Nanoseconds.
  • column.format is 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.

tsx
// ❌ 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:

  1. column.render — if present, used.
  2. Grid-level renderBodyCell — if (1) absent, used.
  3. Default — formattedValue as plain text.

Same for headers (column.renderHeaderrenderHeaderCell → 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-idModifier
Headerabsentdata-pretable-row-select-header
Body cellspresent, "__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:

css
/* 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 isSelected is derived from cell-range selection.
  • Keyboard — how isFocused works.
  • Clipboardformat is reused as the copy serializer.
  • Date formatting — canonical values and native presentation.
  • API reference — full type signatures for PretableColumn, PretableCellRenderInput, PretableHeaderRenderInput, PretableFormatInput.