@reopt-ai/opt-datagrid
v1.5.0
Published
Keyboard-first editable React data grid focused on spreadsheet-like UX and performance.
Downloads
285
Maintainers
Readme
@reopt-ai/opt-datagrid
Keyboard-first editable React data grid package focused on spreadsheet-like UX and practical performance.
Goals
- Fast editing loop for keyboard users
- Independent implementation with minimal dependencies
- Large dataset friendliness via row windowing
Agent skill setup (recommended)
From the consumer project root, install the dedicated skill:
npx skills add reopt-ai/reopt-skills/opt-datagrid-installThen ask your agent: Use the opt-datagrid-install skill to set up
@reopt-ai/opt-datagrid in this project and verify the result. The skill
distinguishes a new install from an upgrade, supports migrations from other
grid packages, idempotently updates the reopt marker block in AGENTS.md (or
CLAUDE.md), reads this installed package's dist/docs/, and runs the
appropriate checks. The source of truth is
reopt-ai/reopt-skills.
Use the command below as the manual fallback.
Install
bun add @reopt-ai/opt-datagrid@reopt-ai/opt-datagrid is published publicly on npmjs.org, so consumers do
not need a custom registry or authentication configuration.
Requires React 19 and a modern browser. No CSS import is needed. Full guides and interactive examples are available in the opt-datagrid documentation.
Usage
"use client";
import * as React from "react";
import { DataGrid, type DataGridColumn } from "@reopt-ai/opt-datagrid";
interface UserRow {
id: number;
name: string;
role: string;
}
const columns: DataGridColumn<UserRow>[] = [
{
id: "name",
title: "Name",
width: 220,
editable: true,
getValue: (row) => row.name,
setValue: (row, nextValue) => ({ ...row, name: nextValue }),
},
{
id: "role",
title: "Role",
width: 180,
editable: true,
getValue: (row) => row.role,
setValue: (row, nextValue) => ({ ...row, role: nextValue }),
},
];
export function Example() {
const [rows, setRows] = React.useState<UserRow[]>([
{ id: 1, name: "Mina", role: "Owner" },
{ id: 2, name: "Joon", role: "Editor" },
]);
return (
<DataGrid
rows={rows}
columns={columns}
getRowId={(row) => String(row.id)}
onRowsChange={(nextRows) => setRows(Array.from(nextRows))}
height={420}
/>
);
}Typed editors
DataGridColumn is value-aware. Text columns still work unchanged, but columns
can now declare typed editors directly:
import type { DataGridAnyColumn } from "@reopt-ai/opt-datagrid";
interface MemberRow {
id: number;
status: "pending" | "active";
enabled: boolean;
quota: number | null;
dueDate: string;
}
const columns: DataGridAnyColumn<MemberRow>[] = [
{
id: "status",
title: "Status",
editable: true,
editor: {
kind: "select",
options: [
{ value: "pending", label: "Pending" },
{ value: "active", label: "Active" },
],
},
getValue: (row) => row.status,
setValue: (row, nextValue) => ({ ...row, status: nextValue }),
},
{
id: "enabled",
title: "Enabled",
editable: true,
editor: { kind: "checkbox" },
getValue: (row) => row.enabled,
setValue: (row, nextValue) => ({ ...row, enabled: nextValue }),
formatValue: (value) => (value ? "Enabled" : "Disabled"),
},
{
id: "quota",
title: "Quota",
editable: true,
editor: { kind: "number", min: 0, step: 1 },
getValue: (row) => row.quota,
setValue: (row, nextValue) => ({ ...row, quota: nextValue }),
formatValue: (value) => (value == null ? "" : String(value)),
},
{
id: "dueDate",
title: "Due date",
editable: true,
editor: { kind: "date" },
getValue: (row) => row.dueDate,
setValue: (row, nextValue) => ({ ...row, dueDate: nextValue }),
},
];Built-in editor kinds:
"text"and"textarea""number""select""checkbox""date""async-combobox""custom"
Useful value hooks:
formatValuefor cell display textserializeValuefor copy / export / search-friendly string outputdeserializeValuefor paste / edit parsing into typed valuesparseInputfor custom draft parsingvalidateValuefor typed validation before commit
Custom editors are attached at the column level via component:
import { type DataGridEditorContext } from "@reopt-ai/opt-datagrid";
function StatusEditor(
context: DataGridEditorContext<MemberRow, MemberRow["status"]>,
) {
const { setDraftValue, commit, cancel } = context;
return (
<div style={{ display: "grid", gap: 8 }}>
<button
type="button"
onClick={() => {
setDraftValue("pending");
commit();
}}
>
Pending
</button>
<button
type="button"
onClick={() => {
setDraftValue("active");
commit();
}}
>
Active
</button>
<button type="button" onClick={cancel}>
Cancel
</button>
</div>
);
}
const columns: DataGridColumn<MemberRow, MemberRow["status"]>[] = [
{
id: "status",
title: "Status",
editable: true,
editor: {
kind: "custom",
component: StatusEditor,
},
getValue: (row) => row.status,
setValue: (row, nextValue) => ({ ...row, status: nextValue }),
},
];component is the custom editor API for reusable editors.
Theme tokens (opt-ui auto integration)
DataGrid reads opt-ui CSS variables when available and falls back to
built-in defaults when they are not present.
Common tokens used by default styles:
--opt-surface,--opt-surface-raised--opt-text,--opt-text-secondary,--opt-text-tertiary--opt-border,--opt-border-subtle,--opt-border-hover--opt-accent,--opt-accent-active,--opt-accent-subtle,--opt-accent-fg--opt-ring--opt-info-subtle,--opt-warning,--opt-warning-subtle--opt-shadow-sm,--opt-shadow-md,--opt-shadow-lg
Keyboard
- Arrow keys: move active cell
- Ctrl/Cmd + Arrow: jump to row/column boundary
- Shift + Arrow: extend range selection
- Enter / F2: start editing
- Edit mode commit:
Enterdown,Shift+EnterupTabright,Shift+TableftCtrl/Cmd+Entercommit without moving
- Tab / Shift+Tab: horizontal move (wrap behavior controlled by
keyboardFlow.wrapMode) - Home / End: row boundary
- Ctrl/Cmd + Home / End: grid boundary
- Ctrl/Cmd + A: select all
- Ctrl/Cmd + C: copy selected cells
- Ctrl/Cmd + X: cut selected cells
- Ctrl/Cmd + V: paste clipboard payload
- Ctrl/Cmd + F: open in-grid search
- Ctrl/Cmd + Z / Y: undo / redo edits
Keyboard flow options
DataGrid supports directional and continuous edit flow:
keyboardFlow.continuousEdit(default:true)keyboardFlow.defaultDirection("right"or"down", default:"right")keyboardFlow.wrapMode("row"or"none", default:"row")keyboardFlow.skipReadonly(default:true)
Use onFinishedEditing to observe accepted commit + movement direction.
Advanced hooks
- Edit pipeline:
onCellCommit,onCellsEdited,onCopy,onCut,onPaste,onDeleteonPastereturns"apply"or"block"onCopy/onCutreturn{ action: "default" | "block" | "override" }
- Validation pipeline:
validateCell,coercePasteValue - Typed editor hooks:
editor,formatValue,serializeValue,deserializeValueparseInput,validateValuedependsOnColumnIds,areValuesEqual
- Selection control:
initialActiveCell,gridSelection,onGridSelectionChange - Row/Column selection:
rowMarkers,rowSelect,columnSelect- row marker clicks preserve row selection while moving the active cell within that row
AI stream invalidations
Remote grids can share the same AI SDK UI message stream as chat or agent
workflows. Use writeDataGridInvalidation on the server and
parseDataGridInvalidationPart on the client to keep streamed invalidations
typed and dedupe-friendly:
import {
parseDataGridInvalidationPart,
writeDataGridInvalidation,
} from "@reopt-ai/opt-datagrid/ai-stream";
writeDataGridInvalidation(
writer,
{
rows: [{ rowId: "row-1", row: { id: "row-1", status: "approved" } }],
totalRowCount: 2500,
snapshotVersion: "snap-42",
},
{ id: "grid-invalidation-42", transient: true },
);
const invalidation = parseDataGridInvalidationPart<Row>(part);- Column control:
onColumnsChange,onColumnResize,onColumnMoved - Header actions:
onHeaderClicked,onHeaderMenuClick,onHeaderMenuAction- header title buttons support pointer and keyboard activation
- Viewport events:
onVisibleRegionChanged+rowBufferPx - Clipboard controls:
copyHeaders(include header row in copy output)maxPasteCells,onPasteOverflowgetCellsForSelection(sync/async custom copy source)
- Performance tuning:
searchDebounceMs(default80)selectionCacheStrategy("set"or"none", default"set")scrollUpdateMode("raf"or"sync", default"raf")getRowIdto stabilize row identity across reorder, insert, and sortvalueCacheto reusegetValueresults across interaction-only rerendersvalueCacheStrategy("row-ref"or"row-id") to control cache identityrendererRefreshModeto control renderer-host reuserowBufferPxto control pixel-based row bufferingmaxRenderedRowsto clamp pathological render windows
- Search control:
showSearch,searchValue,searchResults - Imperative ref API:
focus,scrollTo,getBoundsappendRow,appendColumn,getMouseArgsForPositioncopySelection,cutSelection,pasteText,pasteFromClipboarddeleteSelection,fillSelectionRight,fillSelectionDown
Value cache and renderer reuse
When valueCache is enabled, DataGrid caches cell values by row identity and
column id so active-cell movement, selection, and other interaction-only
updates do not keep re-running getValue.
By default the cache uses row references. If your grid frequently reorders,
sorts, or inserts rows, provide getRowId so the cache and rendered row keys
stay warm across index changes:
<DataGrid
rows={rows}
columns={columns}
valueCache
getRowId={(row) => String(row.id)}
valueCacheStrategy="row-id"
/>For derived columns, declare dependencies with dependsOnColumnIds so cache
invalidation can stay narrow:
const columns: DataGridColumn<UserRow>[] = [
{
id: "name",
title: "Name",
editable: true,
getValue: (row) => row.name,
setValue: (row, nextValue) => ({ ...row, name: nextValue }),
},
{
id: "summary",
title: "Summary",
dependsOnColumnIds: ["name", "role"],
getValue: (row) => `${row.name} · ${row.role}`,
},
];If you use renderCell or customRenderers, you can also provide a refresh
predicate to keep unchanged renderers from rerunning when sibling cells update:
{
id: "status",
title: "Status",
getValue: (row) => row.status,
renderCell: ({ value }) => <StatusBadge value={value} />,
refreshCellRenderer: (prev, next) => prev.value === next.value,
}Remote data source
Use useDataGridRemoteDataSource when the backend is designed around viewport
windows, view sessions, and batched edits:
const remote = useDataGridRemoteDataSource<Row>({
rowCount: 120000,
pageSize: 200,
preloadPages: 1,
getVisibleColumnIds: (region) =>
columns
.slice(region.startCol, region.endCol + 1)
.map((column) => column.id),
openView: async ({ signal }) => {
const response = await fetch("/api/grid/views/open", {
method: "POST",
signal,
});
const payload = await response.json();
return {
viewId: payload.viewId,
rowCount: payload.totalRowCount,
snapshotVersion: payload.snapshotVersion,
};
},
subscribeToInvalidations: ({ viewId, onInvalidate }) => {
const events = new EventSource(`/api/grid/views/${viewId}/events`);
events.addEventListener("invalidate", (event) => {
const payload = JSON.parse((event as MessageEvent<string>).data);
onInvalidate({
rows: payload.rows,
rowCount: payload.totalRowCount,
snapshotVersion: payload.snapshotVersion,
movedRowIds: payload.movedRowIds,
invalidateRanges: payload.invalidateRanges,
});
});
return () => events.close();
},
makePlaceholderRow: (rowIndex) => ({
id: `loading-${rowIndex}`,
name: "Loading...",
}),
loadRows: async ({ start, end, viewId, visibleColumnIds, signal }) => {
const params = new URLSearchParams({
start: String(start),
end: String(end),
});
if (visibleColumnIds?.length) {
params.set("columns", visibleColumnIds.join(","));
}
const response = await fetch(
`/api/grid/views/${viewId}/window?${params.toString()}`,
{
signal,
},
);
const payload = await response.json();
return {
rows: payload.rows,
rowCount: payload.totalRowCount,
snapshotVersion: payload.snapshotVersion,
};
},
saveEdits: async ({ edits, viewId, snapshotVersion, signal }) => {
const response = await fetch(`/api/grid/views/${viewId}/edits`, {
method: "POST",
signal,
body: JSON.stringify({ edits, snapshotVersion }),
});
const payload = await response.json();
return {
rows: payload.rows,
snapshotVersion: payload.snapshotVersion,
movedRowIds: payload.movedRowIds,
invalidateRanges: payload.invalidateRanges,
rejectedEdits: payload.rejectedEdits,
};
},
revalidateAfterSave: "affected-pages",
});
<DataGrid
rows={remote.rows}
columns={columns}
onVisibleRegionChanged={remote.onVisibleRegionChanged}
onCellsEdited={remote.onCellsEdited}
/>;
if (remote.rejectedEdits.length > 0) {
console.warn(remote.rejectedEdits);
}
console.log(remote.telemetry);Backend contract checklist:
openViewshould materialize or cache a stable sorted/filtered view and returnviewId,rowCount, andsnapshotVersion.loadRowsshould acceptstart,end, andvisibleColumnIdsso the server can do range fetch plus column projection.saveEditsshould handle batched cell patches and return canonical rows,rejectedEdits,movedRowIds, andinvalidateRangeswhen needed.subscribeToInvalidationsshould push the same invalidation shape for multi-user changes on the sameviewId.
Error handling (remote data source)
useDataGridRemoteDataSource exposes granular error state for building
resilient UIs. Use these fields to show inline error indicators, retry
controls, and fallback states.
Error state fields
| Field | Type | Description |
| --------------- | ------------------------------ | ------------------------------------------- |
| lastViewError | Error \| null | openView failure — grid cannot initialize |
| lastLoadError | Error \| null | Most recent loadRows failure |
| lastSaveError | Error \| null | Most recent saveEdits failure |
| failedPages | number[] | Page indices that failed to load |
| failedEdits | DataGridCellEdit[] | Edits that could not be saved |
| rejectedEdits | DataGridRemoteRejectedEdit[] | Server-rejected edits (e.g. validation) |
| isViewReady | boolean | false until openView succeeds |
| isViewOpening | boolean | true while openView is in flight |
Recovery methods
| Method | Description |
| ----------------------- | ------------------------------------ |
| retryPage(page) | Re-fetch a single failed page |
| retryFailedEdits() | Re-submit all failed edits |
| refreshVisiblePages() | Force-reload currently visible pages |
Example: error boundary pattern
function RemoteGrid({ columns }: { columns: DataGridAnyColumn<Row>[] }) {
const remote = useDataGridRemoteDataSource<Row>({/* ... */});
// 1. View initialization error — show full-page fallback
if (remote.lastViewError) {
return (
<div role="alert">
<p>그리드를 초기화할 수 없습니다: {remote.lastViewError.message}</p>
<button onClick={() => window.location.reload()}>새로고침</button>
</div>
);
}
// 2. View still opening — show skeleton
if (!remote.isViewReady) {
return <div>Loading...</div>;
}
return (
<div>
{/* 3. Page-level load errors — inline banner */}
{remote.failedPages.length > 0 && (
<div role="alert">
{remote.failedPages.length}개 페이지 로드 실패
<button onClick={() => remote.failedPages.forEach(remote.retryPage)}>
재시도
</button>
</div>
)}
{/* 4. Save errors — toast or inline */}
{remote.lastSaveError && (
<div role="alert">
저장 실패: {remote.lastSaveError.message}
<button onClick={remote.retryFailedEdits}>재시도</button>
</div>
)}
{/* 5. Server-rejected edits — field-level feedback */}
{remote.rejectedEdits.length > 0 && (
<div role="alert">
{remote.rejectedEdits.map((r, i) => (
<p key={i}>
Column "{r.columnId}": {r.message ?? "서버에서 거부됨"}
</p>
))}
</div>
)}
<DataGrid
rows={remote.rows}
columns={columns}
height={600}
onVisibleRegionChanged={remote.onVisibleRegionChanged}
onCellsEdited={remote.onCellsEdited}
/>
</div>
);
}Error hierarchy
- View error (
lastViewError): fatal — grid cannot render. Show full fallback. - Page errors (
failedPages): partial — some rows show placeholders. Offer per-page retry. - Save errors (
lastSaveError,failedEdits): optimistic edits rolled back. Offer batch retry. - Rejected edits (
rejectedEdits): server validation failures. Show field-level messages.
Utilities
useDataGridUndoRedo: standalone undo/redo history stack utilityuseDataGridRemoteDataSource: viewport-driven remote read/write source with optimistic edit batchinguseAsyncDataSource: read-only convenience wrapper overuseDataGridRemoteDataSource
Development
bun run --filter @reopt-ai/opt-datagrid test
bun run --filter @reopt-ai/opt-datagrid typecheck
bun run --filter @reopt-ai/opt-datagrid buildMaintainer release
유지보수자 배포는 직접 npm publish 또는 bun run release*를 사용하지
않고 design-publish 워크플로우만 사용합니다.
Benchmarking
Run the local benchmark harness to compare practical grid scenarios without adding cost to the regular test suite:
bun run --filter @reopt-ai/opt-datagrid benchmarkUseful options:
--rows <n>and--cols <n>to scale the dataset--iterations <n>to control measured repetitions per scenario--scenario <list>to run a subset such asactive-move,search-refine--baseline [path]to compare the current run against a saved JSON baseline--save-baseline [path]to write the current run as a reusable baseline--history [path]to append the current run to a history JSON and print a recent trend table--history-limit <n>to control how many recent reports the trend summary considers--fail-on-regressionto exit non-zero when compared metrics regress beyond the threshold--regression-threshold <n>to control regression sensitivity (default5)--jsonto emit machine-readable output
Example:
bun run --filter @reopt-ai/opt-datagrid benchmark -- --rows 10000 --cols 100 --iterations 5Default baseline helpers:
bun run --filter @reopt-ai/opt-datagrid benchmark:save-baseline
bun run --filter @reopt-ai/opt-datagrid benchmark:compare
bun run --filter @reopt-ai/opt-datagrid benchmark:history