OPERATOR

DataGrid

Selection, sorting, and pagination

SELECT PAGE
Station telemetry
IDSTATIONLATENCY / MSSTATUSSELECT
ST-003Charlie station18success
ST-001Alpha station24success
ST-005Echo station32success

5 ROWS · 0 SELECTED

PAGE 1 OF 2
const [selected, setSelected] = useState<string[]>([]);
const [sort, setSort] = useState<DataGridSort | null>(null);
 
<DataGrid
  caption="Station telemetry"
  columns={columns}
  data={rows}
  getRowId={(row) => String(row.id)}
  selectedRowIds={selected}
  onSelectionChange={setSelected}
  sort={sort}
  onSortChange={setSort}
  pageSize={10}
/>;

Rows need unique, stable IDs. Enable selection with selectedRowIds or onSelectionChange; Select page affects only displayed rows. Selections outside filtered results persist until the owner removes them.

Controlled state and page changes

Controlled props require updated values from their owner. Omit page, sort, or visibleColumnKeys for internal state. Pages start at 1. Filtering clamps unavailable pages and reports the correction once through onPageChange. Sorting preserves the page; reset it in the owner if needed.

Dense, empty, and loading

Dense station telemetry
IDSTATIONLATENCY / MSSTATUS
ST-001Alpha station24success
ST-002Bravo station51warning

2 ROWS

PAGE 1 OF 1
Unavailable station telemetry
IDSTATIONLATENCY / MSSTATUS
No stations match this view.

0 ROWS

PAGE 1 OF 1
Updating station telemetry
IDSTATIONLATENCY / MSSTATUS
ST-001Alpha station24success

Loading results.

1 ROW

PAGE 1 OF 1

loading retains data, announces updates, and disables grid controls. disabled also blocks row activation. Disable custom cell controls separately. With every column hidden, a message appears and Columns remains available.

Server-owned results

Pass the current page as data, mode="server", and the total filtered rowCount. The owner handles page/sort requests and fetches results; the grid does not re-sort or slice server data.

<DataGrid
  mode="server"
  caption="Remote station telemetry"
  columns={columns}
  data={pageRows}
  getRowId={(row) => String(row.id)}
  rowCount={totalRows}
  page={page}
  onPageChange={setPage}
  pageSize={25}
  sort={sort}
  onSortChange={setSort}
  loading={loading}
/>

Custom cells

Columns support DataTable formatting plus render(value, row) and compare(a, b). Comparisons use raw values. onRowClick receives the original row and its index in supplied data, even after sorting. Cell controls do not trigger row actions.

Keep getRowId and each column's compare pure: the same row always gives the same ID, and the same two values always compare the same way. The grid reads row IDs again only when data changes, so an inline getRowId costs nothing on a re-render. It sorts again when data, the sort, or the sorted column's compare changes, so define columns, and their compare functions, outside render or memoize them.

Column widths and pinning

Open Columns to change visibility, set numeric widths, or pin columns to the leading edge. Width controls accept direct entry; Arrow keys resize by 8 CSS pixels and Home/End use the bounds. Column descriptors accept minWidth and maxWidth (defaults 96 and 640). Pinned keys set the leading order; unpinned columns retain descriptor order. Hidden pinned columns retain their preference for when they become visible again.

Pass columnWidths / onColumnWidthsChange and pinnedColumnKeys / onPinnedColumnsChange to control these settings. Widths are clamped for rendering; controlled owners apply callback values. Unconfigured tables retain automatic sizing. Resizing, pinning, or virtualization activates fixed column sizing; unspecified widths use 160 CSS pixels or a pixel width from the descriptor.

Large result sets

2,000 supplied observations. Open Columns to resize or pin. Selection follows stable IDs when scrolling or switching views.

SELECT RESULTS
Large station inventory
IDStationLatency / msObservationSELECT
ST-0001Station 10Intermittent uplink. Last observation retained while the next scheduled report is pending.
ST-0002Station 213Observation received.
ST-0003Station 326Observation received.
ST-0004Station 439Observation received.
ST-0005Station 552Intermittent uplink. Last observation retained while the next scheduled report is pending.
ST-0006Station 665Observation received.
ST-0007Station 778Observation received.
ST-0008Station 891Observation received.
ST-0009Station 9104Intermittent uplink. Last observation retained while the next scheduled report is pending.
ST-0010Station 10117Observation received.

2000 ROWS · 0 SELECTED

<DataGrid
  caption="Large station inventory"
  columns={columns}
  data={rows}
  getRowId={(row) => String(row.id)}
  virtualization={{ height: 320, estimateRowHeight: 48, overscan: 3 }}
  pageSize={25}
/>

Virtualization is opt-in. Omit rowHeight to measure variable heights with ResizeObserver; estimateRowHeight supplies the initial estimate (40 by default). Wrapped observations then update the scroll geometry as rows are measured. Without ResizeObserver, the initial measurements and estimate remain available; use the paginated alternative when needed.

For a dense list with predictable content, pass { height: 320, rowHeight: 40 }. Fixed-height rows clip content that exceeds the specified height; use measured rows or the paginated view to read wrapped content. Minimum row height is 24 CSS pixels. Only mounted cells execute custom render callbacks. Overscan defaults to 3 rows and never drops below 1.

Use paginated view is always available with virtualization. viewMode and onViewModeChange can own this choice. In client mode, the virtual view windows the complete filtered result set; Select results changes that full set of IDs. The paginated alternative uses page and pageSize. In server mode both views use the supplied page, and Select page affects only its IDs; pagination remains available.

The implementation retains native table headers, rows, and cells. aria-rowcount includes the header, and each mounted row has its global aria-rowindex, including the server page offset. The focused row and its adjacent rows remain mounted while scrolling; tabbing through their controls continues in row order and brings the newly focused row into view. Switching views through the outside toggle retains focus on that toggle. Stable IDs are required. When an application removes the focused entity itself, that application must decide the appropriate focus destination.

Save and restore a controlled view

Save and restore this view in this browser. Source rows and selection are not stored.

SELECT PAGE
Saved station view
IDStationLatency / msObservationSELECT
ST-0001Station 10Intermittent uplink. Last observation retained while the next scheduled report is pending.
ST-0002Station 213Observation received.
ST-0003Station 326Observation received.
ST-0004Station 439Observation received.
ST-0005Station 552Intermittent uplink. Last observation retained while the next scheduled report is pending.

25 ROWS · 0 SELECTED

PAGE 1 OF 5

A view consists of ordinary controlled props. The grid does not read browser storage. This complete example persists only the view preferences. Selection stays in memory and source rows remain owned by the application. Restoration rejects malformed or obsolete preferences, and both storage operations handle unavailable storage.

import { useState } from "react";
import {
  Button,
  DataGrid,
  type DataGridSort,
  type DataGridColumnWidths,
  type DataGridViewMode,
  type DataTableRow,
} from "@operator-ds/react";
 
const columns = [
  { key: "id", label: "ID" },
  { key: "name", label: "Station" },
  { key: "latency", label: "Latency / ms", as: "number" as const },
];
type View = {
  version: 1;
  sort: DataGridSort | null;
  page: number;
  pageSize: number;
  visible: string[];
  widths: DataGridColumnWidths;
  pinned: string[];
  viewMode: DataGridViewMode;
  dense: boolean;
};
const initial: View = {
  version: 1,
  sort: null,
  page: 1,
  pageSize: 25,
  visible: columns.map((column) => column.key),
  widths: {},
  pinned: [],
  viewMode: "paginated",
  dense: false,
};
const storageKey = "station-view.v1";
 
function parseView(raw: string | null): View | null {
  try {
    const value: unknown = JSON.parse(raw ?? "null");
    if (!value || typeof value !== "object" || Array.isArray(value)) return null;
    const v = value as Record<string, unknown>;
    const keys = new Set(columns.map((column) => column.key));
    const keyList = (list: unknown) =>
      Array.isArray(list) &&
      list.every((key) => typeof key === "string" && keys.has(key)) &&
      new Set(list).size === list.length;
    if (v.version !== 1 || !keyList(v.visible) || !keyList(v.pinned)) return null;
    if (v.viewMode !== "paginated" && v.viewMode !== "virtualized") return null;
    if (typeof v.dense !== "boolean") return null;
    if (typeof v.page !== "number" || !Number.isSafeInteger(v.page) || v.page < 1) return null;
    if (
      typeof v.pageSize !== "number" ||
      !Number.isInteger(v.pageSize) ||
      v.pageSize < 1 ||
      v.pageSize > 100
    )
      return null;
    const sort = v.sort as Record<string, unknown> | null;
    if (
      sort !== null &&
      (!sort ||
        typeof sort !== "object" ||
        !keys.has(String(sort.key)) ||
        (sort.direction !== "asc" && sort.direction !== "desc"))
    )
      return null;
    if (!v.widths || typeof v.widths !== "object" || Array.isArray(v.widths)) return null;
    if (
      !Object.entries(v.widths).every(
        ([key, width]) =>
          keys.has(key) &&
          typeof width === "number" &&
          Number.isFinite(width) &&
          width >= 48 &&
          width <= 640,
      )
    )
      return null;
    return v as View;
  } catch {
    return null;
  }
}
 
export function SavedStations({ rows }: { rows: DataTableRow[] }) {
  const [view, setView] = useState<View>(initial);
  const [selected, setSelected] = useState<string[]>([]);
  const [message, setMessage] = useState("");
  const update = (next: Partial<View>) => setView((old) => ({ ...old, ...next }));
  const save = () => {
    try {
      localStorage.setItem(storageKey, JSON.stringify(view));
      setMessage("View saved.");
    } catch {
      setMessage("Storage unavailable. Current view remains usable.");
    }
  };
  const restore = () => {
    try {
      const saved = parseView(localStorage.getItem(storageKey));
      setView(saved ?? initial);
      setMessage(saved ? "View restored." : "No valid saved view. Defaults restored.");
    } catch {
      setMessage("Storage unavailable. Current view remains usable.");
    }
  };
  return (
    <>
      <Button onClick={save}>Save view</Button>
      <Button onClick={restore}>Restore view</Button>
      <p role="status">{message}</p>
      <DataGrid
        caption="Stations"
        columns={columns}
        data={rows}
        getRowId={(row) => String(row.id)}
        selectedRowIds={selected}
        onSelectionChange={setSelected}
        sort={view.sort}
        onSortChange={(sort) => update({ sort, page: 1 })}
        page={view.page}
        onPageChange={(page) => update({ page })}
        pageSize={view.pageSize}
        dense={view.dense}
        visibleColumnKeys={view.visible}
        onVisibleColumnsChange={(visible) => update({ visible })}
        columnWidths={view.widths}
        onColumnWidthsChange={(widths) => update({ widths })}
        pinnedColumnKeys={view.pinned}
        onPinnedColumnsChange={(pinned) => update({ pinned })}
        viewMode={view.viewMode}
        onViewModeChange={(viewMode) => update({ viewMode })}
        virtualization={{ height: 320, estimateRowHeight: 40 }}
      />
    </>
  );
}

Saved widths are still bounded by current column descriptors. A stale saved page is clamped to the current result set and reported through onPageChange. A future schema can migrate preferences by version before supplying the controlled props.

Cell editing and grouping are outside this component's contract.

Props

PROPTYPEDEFAULTREQUIRED
columns

DataTableColumn descriptors plus render(value, originalRow) for display and compare(a, b) for raw-value sorting. compare must be pure; a new compare function sorts again, so define columns outside render or memoize them. Columns sort unless sortable is false. Keys must be unique.

array—yes
data

Client mode: complete filtered row set. Server mode: current page only; order and row count are preserved.

array—yes
getRowId

(originalRow) => string. Return a unique, stable ID across filtering, sorting, and pagination; do not use an array index. Keep it pure: IDs are read again only when data changes, not when only the function does.

function—yes
caption

Accessible native table caption and controls group name.

string"Data grid"no
selectedRowIds

Controlled IDs. Supplying this or onSelectionChange enables selection. IDs outside the displayed data are preserved.

array—no
onSelectionChange

(ids: string[]) => void. Select page changes only current-page IDs. Without selectedRowIds, selection is stored internally.

function—no
sort

Controlled { key: string, direction: 'asc' | 'desc' } or null for source order. Omit for internal sorting state. Uses raw values before custom rendering and announces the active direction with aria-sort on the corresponding table header.

node—no
onSortChange

(sort: DataGridSort | null) => void. Server mode only reports the requested sort; the caller supplies sorted rows.

function—no
page

One-based controlled page. Omit for internal pagination state. Out-of-range values are clamped and reported once through onPageChange.

number1no
onPageChange

(page: number) => void. A supplied page remains controlled until its owner updates it.

function—no
pageSize

Rows per client page and denominator for server page counts. Invalid or less-than-one values use 10; fractional values round down.

number10no
modeenum
client | server
"client"no
rowCount

Total filtered row count in server mode. Defaults to data.length; provide the total to enable navigation beyond the supplied page.

number—no
visibleColumnKeys

Controlled displayed column keys. Omit for internal visibility state; all columns are visible initially. An empty list shows a recovery message.

array—no
onVisibleColumnsChange

(keys: string[]) => void. Receives visibility changes from the COLUMNS controls.

function—no
columnWidths

Controlled Record<columnKey, CSS pixel width>. Omit for internal widths. Values are clamped to each column's minWidth/maxWidth; default width is 160 or a pixel width from the descriptor. Stored keys outside the current columns are preserved in resize callbacks.

node—no
onColumnWidthsChange

(widths: DataGridColumnWidths) => void. Columns controls support direct numeric entry, Arrow keys (8 pixels), and Home/End (bounds).

function—no
pinnedColumnKeys

Controlled leading-edge pinned keys in display order. Omit for internal state. Hidden pinned columns retain their preference; unknown keys are ignored for display.

array—no
onPinnedColumnsChange

(keys: string[]) => void. Called by keyboard-accessible Pin checkboxes in Columns.

function—no
virtualization

Opt-in { height: number, rowHeight?: number, estimateRowHeight?: number, overscan?: number }, CSS pixels. Fixed rowHeight clips overlong cell contents; omit rowHeight to measure wrapped rows with ResizeObserver, starting at estimateRowHeight (default 40). Minimum row height 24; default overscan 3, minimum 1. Client mode windows all filtered rows; server mode windows only the supplied page.

node—no
viewMode

Controlled display mode. Virtualization must be supplied for virtualized mode. Defaults to virtualized with configuration, paginated without it. The built-in toggle always offers a paginated native-table alternative.

enum
paginated | virtualized
—no
onViewModeChange

(mode: DataGridViewMode) => void. Controlled owners must apply the requested mode for the fallback toggle to update the table.

function—no
loading

Marks the table busy, announces loading, and disables grid-owned controls and row activation while retaining supplied data.

booleanfalseno
disabled

Disables grid-owned actions and row activation. Custom interactive cell content remains the caller's responsibility.

booleanfalseno
densebooleanfalseno
emptyMessagestring"No results."no
onRowClick

(originalRow, indexInSuppliedData) => void. Uses the existing DataTable's native first-cell button; independent cell controls do not activate the row.

function—no

Accessibility

  • Named native table with keyboard-accessible controls and row-action buttons.
  • When its columns do not fit, the table scrolls sideways inside its own region, named after the caption ("Station telemetry scroll viewport"), which takes keyboard focus while it scrolls. The page never scrolls sideways.
  • Row checkboxes use stable IDs; Select page exposes partial selection as mixed. Each checkbox is named with the words beside it: "Select page" or "Select results", and "Pin to start: Station" in Columns.
  • Page changes are announced. Loading sets aria-busy on the table and writes "Loading results." into a status message that is on the page from the start, so screen readers announce it.
  • Column visibility uses a labeled fieldset. Controls have visible focus and minimum-size targets.
  • Numeric width controls and pin checkboxes work with keyboard alone; preferences remain controlled when supplied.
  • Virtual windows preserve native row indexing and focused rows, with a paginated alternative for assistive-technology navigation and expanded content.

Manifest entry

<DataGrid
  caption="Station telemetry"
  columns={[{ key: "name", label: "STATION" }, { key: "latency", label: "LATENCY / MS", as: "number" }]}
  data={rows}
  getRowId={(row) => String(row.id)}
  selectedRowIds={selected}
  onSelectionChange={setSelected}
  pageSize={10}
/>
View agent manifest
{
  "description": "Native table with stable-ID selection, sorting, controlled column widths and pinning, saved-view state, pagination, and optional virtualization.",
  "props": {
    "columns": {
      "type": "array",
      "items": {
        "key": "string (required, unique)",
        "label": "string (required)",
        "as": "text | number | code | timestamp | badge",
        "highlight": "boolean (default: false)",
        "width": "string (CSS width; a px value seeds the column width)",
        "align": "left | right",
        "sortable": "boolean",
        "render": "(value, row) => ReactNode",
        "compare": "(a, b) => number",
        "minWidth": "number (CSS pixels; default 96; minimum 48)",
        "maxWidth": "number (CSS pixels; default 640)"
      },
      "required": true,
      "description": "DataTableColumn descriptors plus render(value, originalRow) for display and compare(a, b) for raw-value sorting. compare must be pure; a new compare function sorts again, so define columns outside render or memoize them. Columns sort unless sortable is false. Keys must be unique."
    },
    "data": {
      "type": "array",
      "required": true,
      "description": "Client mode: complete filtered row set. Server mode: current page only; order and row count are preserved."
    },
    "getRowId": {
      "type": "function",
      "required": true,
      "description": "(originalRow) => string. Return a unique, stable ID across filtering, sorting, and pagination; do not use an array index. Keep it pure: IDs are read again only when data changes, not when only the function does."
    },
    "caption": {
      "type": "string",
      "default": "Data grid",
      "description": "Accessible native table caption and controls group name."
    },
    "selectedRowIds": {
      "type": "array",
      "description": "Controlled IDs. Supplying this or onSelectionChange enables selection. IDs outside the displayed data are preserved."
    },
    "onSelectionChange": {
      "type": "function",
      "description": "(ids: string[]) => void. Select page changes only current-page IDs. Without selectedRowIds, selection is stored internally."
    },
    "sort": {
      "type": "node",
      "description": "Controlled { key: string, direction: 'asc' | 'desc' } or null for source order. Omit for internal sorting state. Uses raw values before custom rendering and announces the active direction with aria-sort on the corresponding table header."
    },
    "onSortChange": {
      "type": "function",
      "description": "(sort: DataGridSort | null) => void. Server mode only reports the requested sort; the caller supplies sorted rows."
    },
    "page": {
      "type": "number",
      "default": 1,
      "description": "One-based controlled page. Omit for internal pagination state. Out-of-range values are clamped and reported once through onPageChange."
    },
    "onPageChange": {
      "type": "function",
      "description": "(page: number) => void. A supplied page remains controlled until its owner updates it."
    },
    "pageSize": {
      "type": "number",
      "default": 10,
      "description": "Rows per client page and denominator for server page counts. Invalid or less-than-one values use 10; fractional values round down."
    },
    "mode": {
      "type": "enum",
      "values": [
        "client",
        "server"
      ],
      "default": "client"
    },
    "rowCount": {
      "type": "number",
      "description": "Total filtered row count in server mode. Defaults to data.length; provide the total to enable navigation beyond the supplied page."
    },
    "visibleColumnKeys": {
      "type": "array",
      "description": "Controlled displayed column keys. Omit for internal visibility state; all columns are visible initially. An empty list shows a recovery message."
    },
    "onVisibleColumnsChange": {
      "type": "function",
      "description": "(keys: string[]) => void. Receives visibility changes from the COLUMNS controls."
    },
    "columnWidths": {
      "type": "node",
      "description": "Controlled Record<columnKey, CSS pixel width>. Omit for internal widths. Values are clamped to each column's minWidth/maxWidth; default width is 160 or a pixel width from the descriptor. Stored keys outside the current columns are preserved in resize callbacks."
    },
    "onColumnWidthsChange": {
      "type": "function",
      "description": "(widths: DataGridColumnWidths) => void. Columns controls support direct numeric entry, Arrow keys (8 pixels), and Home/End (bounds)."
    },
    "pinnedColumnKeys": {
      "type": "array",
      "description": "Controlled leading-edge pinned keys in display order. Omit for internal state. Hidden pinned columns retain their preference; unknown keys are ignored for display."
    },
    "onPinnedColumnsChange": {
      "type": "function",
      "description": "(keys: string[]) => void. Called by keyboard-accessible Pin checkboxes in Columns."
    },
    "virtualization": {
      "type": "node",
      "description": "Opt-in { height: number, rowHeight?: number, estimateRowHeight?: number, overscan?: number }, CSS pixels. Fixed rowHeight clips overlong cell contents; omit rowHeight to measure wrapped rows with ResizeObserver, starting at estimateRowHeight (default 40). Minimum row height 24; default overscan 3, minimum 1. Client mode windows all filtered rows; server mode windows only the supplied page."
    },
    "viewMode": {
      "type": "enum",
      "values": [
        "paginated",
        "virtualized"
      ],
      "description": "Controlled display mode. Virtualization must be supplied for virtualized mode. Defaults to virtualized with configuration, paginated without it. The built-in toggle always offers a paginated native-table alternative."
    },
    "onViewModeChange": {
      "type": "function",
      "description": "(mode: DataGridViewMode) => void. Controlled owners must apply the requested mode for the fallback toggle to update the table."
    },
    "loading": {
      "type": "boolean",
      "default": false,
      "description": "Marks the table busy, announces loading, and disables grid-owned controls and row activation while retaining supplied data."
    },
    "disabled": {
      "type": "boolean",
      "default": false,
      "description": "Disables grid-owned actions and row activation. Custom interactive cell content remains the caller's responsibility."
    },
    "dense": {
      "type": "boolean",
      "default": false
    },
    "emptyMessage": {
      "type": "string",
      "default": "No results."
    },
    "onRowClick": {
      "type": "function",
      "description": "(originalRow, indexInSuppliedData) => void. Uses the existing DataTable's native first-cell button; independent cell controls do not activate the row."
    }
  },
  "composable_with": [
    "Card",
    "ComboBox"
  ],
  "never_inside": [
    "DataTable",
    "DataGrid"
  ],
  "example": "<DataGrid\n  caption=\"Station telemetry\"\n  columns={[{ key: \"name\", label: \"STATION\" }, { key: \"latency\", label: \"LATENCY / MS\", as: \"number\" }]}\n  data={rows}\n  getRowId={(row) => String(row.id)}\n  selectedRowIds={selected}\n  onSelectionChange={setSelected}\n  pageSize={10}\n/>"
}

On this page