Grid The PretableSurface component

The PretableSurface component

<PretableSurface> is the unopinionated grid component — bring your own header and chrome.

<PretableSurface> is the unopinionated grid component. Same engine as <Pretable>, but every prop is yours to set. The website's homepage hero and the bench harness both render this component directly — what we measure is what you ship.

tsx
import { PretableSurface, type PretableColumn } from "@pretable/react"; interface Event extends Record<string, unknown> { id: string; timestamp: string; kind: string; message: string; status: "ok" | "warn" | "error"; } const columns: PretableColumn<Event>[] = [ { id: "timestamp", header: "Time", widthPx: 92, pinned: "left" }, { id: "kind", header: "Kind", widthPx: 180 }, { id: "message", header: "Message", widthPx: 420, wrap: true }, { id: "status", header: "Status", widthPx: 80 }, ]; export function EventStream({ events }: { events: Event[] }) { return ( <PretableSurface<Event> ariaLabel="Live event stream" columns={columns} getRowId={(row) => row.id} rows={events} viewportHeight={520} /> ); }

When to use this vs <Pretable>

You want…Reach for
A grid in 3 props, sensible defaults<Pretable>
Custom cell renderers, telemetry, controlled interaction state, or fine-grained sizing<PretableSurface>
To drive everything yourself with hooksusePretable

<Pretable> is a 115-line wrapper around <PretableSurface> with hardcoded viewportHeight={320}, hardcoded cell renderers, and no telemetry. If any of that doesn't fit, drop down to the surface.

Props

PropTypeRequiredDescription
ariaLabelstringyesAccessible name for the grid (role="grid" element).
columnsPretableColumn<TRow>[]yesColumn definitions.
rowsTRow[]yesRow data. Generic over your row type.
getRowId(row: TRow, index: number) => stringyesStable row key — required (no fallback).
viewportHeightnumbernoPixels of vertical viewport. If omitted, the grid fills its container.
viewportStyleCSSPropertiesnoInline style merged onto the viewport <div>. Useful for contain: none and content-visibility.
autosizeboolean | AutosizeOptionsnoAuto-fit columns. Defaults to off.
overscannumbernoRows rendered outside the visible window. Defaults to 6.
renderBodyCell(input: BodyCellInput) => ReactNodenoReplace the default cell renderer.
renderHeaderCell(input: HeaderCellInput) => ReactNodenoReplace the default header renderer.
getBodyCellClassName / getBodyCellProps(input) => string | objectnoPer-cell class / prop hooks.
getHeaderCellClassName / getHeaderCellProps(input) => string | objectnoPer-header-cell class / prop hooks.
getRowClassName / getRowProps(input) => string | objectnoPer-row class / prop hooks (e.g. status-driven row tinting).
statePretableSurfaceState | null   (@experimental)noSlice-based controlled state injection: { sort?, filters?, selection?, focus? }. Each slice is independently controlled — provide it to force the engine, omit to let the engine own that slice.
onTelemetryChange(telemetry: PretableTelemetry) => voidnoCallback fired when row counts / focus / selection change. Must be useCallback-stable — an inline arrow refires the effect every render and can hit React's max-update-depth on hot paths.
onGridReady(grid: PretableGrid<TRow>) => voidnoHand back the grid handle so you can drive sort, filter, selection, transactions imperatively.
onSortChange(sort: { columnId, direction } | null) => voidnoFired when sort direction changes.
onSelectionChange(next: PretableSelectionState) => voidnoFired on user-induced selection changes (keyboard, click). In controlled mode (state.selection provided), the consumer reads this to update their own state.
onFocusChange(next: PretableFocusState) => voidnoFired on user-induced focus changes. Same controlled-mode pattern as onSelectionChange.
onColumnWidthsChange(next: Record<string, number>) => voidnoFired on drag-end after a user resizes a column (or on dblclick autosize). Programmatic grid.setColumnWidth does not fire this. Payload is the full widths map for all data columns.
onColumnOrderChange(next: readonly string[]) => voidnoFires on user-initiated reorder drag-end. Payload is the new column id order (excluding the synthetic row-select column).
onColumnPinnedChange(next: Record<string, "left" | null>) => voidnoFires when pin state changes (e.g., cross-boundary reorder). Payload is the full pinned map for data columns.
onSelectedRowIdChange(rowId: string | null) => voidnoFired when single-row selection changes (Phase 1 compatibility helper).
tabBehavior"wrap-rows" | "exit"noTab key behavior. "wrap-rows" (default) moves Tab right and wraps to next row at end; "exit" lets Tab leave the grid (strict ARIA grid).
selectFocusedRowOnArrowKeybooleannoMake ArrowUp / ArrowDown move both focus AND selection. Defaults to false.

Selection, keyboard, clipboard

<PretableSurface> ships full selection, keyboard navigation, and clipboard support out of the box. Each has its own page:

  • Selection — cell-range model, click + drag, three-state checkbox column, controlled state.
  • Keyboard — full keyboard contract, tabBehavior config, ARIA grid pattern.
  • Clipboard — Cmd/Ctrl+C with TSV defaults, format, onCopy override, aria-live announcements.

Column layout

  • Column layout — resize, reorder, pin, autosize, controlled state.

Telemetry

onTelemetryChange is how you observe the grid from outside React renders. The shape:

ts
interface PretableTelemetry { focusedRowId: string | null; rowModelRowCount: number; // post-filter renderedRowCount: number; // currently in DOM selectedRowId: string | null; totalRowCount: number; // pre-filter totalHeight: number; // virtualized scroll height (px) visibleRowCount: number; visibleRowRange: { start: number; end: number }; }

Stability is critical. If you pass an inline arrow like onTelemetryChange={(t) => setX(t)}, the effect that dispatches it refires every render — combined with setX triggering a re-render, you can hit "Maximum update depth exceeded." Use useCallback with a ref:

tsx
const telemetryRef = useRef<PretableTelemetry | null>(null); const onTelemetryChange = useCallback((t: PretableTelemetry) => { telemetryRef.current = t; }, []);

If you only need fps / frame timing, measure those with requestAnimationFrame deltas in a separate hook (the website's useFrameStats is the model).

Streaming example

The website's homepage hero is a <PretableSurface> driven by a requestAnimationFrame loop with batched setRows. Same pattern, simplified:

tsx
import { useEffect, useRef, useState } from "react"; import { PretableSurface, type PretableColumn } from "@pretable/react"; interface Row extends Record<string, unknown> { id: string; message: string; } const columns: PretableColumn<Row>[] = [ { id: "id", header: "ID", widthPx: 80 }, { id: "message", header: "Message", widthPx: 480, wrap: true }, ]; export function StreamingGrid() { const [rows, setRows] = useState<Row[]>([]); const seqRef = useRef(0); useEffect(() => { let raf = 0; let pending: Row[] = []; const tick = () => { // produce ~16 events per frame at 60fps = ~1k/sec for (let i = 0; i < 16; i++) { const seq = seqRef.current++; pending.push({ id: `seq-${seq}`, message: `event #${seq}` }); } if (pending.length > 0) { const batch = pending; pending = []; // Single setState per frame — batching matters for high rates setRows((prev) => [...batch.reverse(), ...prev].slice(0, 200)); } raf = requestAnimationFrame(tick); }; raf = requestAnimationFrame(tick); return () => cancelAnimationFrame(raf); }, []); return ( <PretableSurface<Row> ariaLabel="Streaming demo" columns={columns} getRowId={(row) => row.id} rows={rows} viewportHeight={520} /> ); }

Key tips:

  • Batch setState per frame. The hero's homepage demo emits 1k events/sec; one setState per emission would burn the page. One per requestAnimationFrame is safe.
  • Cap the buffer. Sliding window of ~200 rows keeps memory + render cost bounded while still feeling alive.
  • Use wrap: true on text columns — multi-line cells with variable heights are the wedge. The grid's measurement reconciliation handles them without layout thrash.

Sibling presets

@pretable/react ships two more opinionated presets on top of <PretableSurface>:

  • <InspectionGrid> — label-above-value cell layout, monospace, fixed 460px viewport. Good for log-style data where each cell is a {label, value} pair.
  • <LabeledGridSurface> — like <InspectionGrid> but lets you customize the value formatter.

If your use case looks like log inspection, start with <InspectionGrid>. Otherwise the bare <PretableSurface> is the right primitive.

Where to go next