OGrid exposes hooks that provide the grid logic without imposing any UI.
Pair useHeadlessGrid with the spreadsheet hooks to add inline edit, range
selection, fill handle, clipboard, undo/redo, and keyboard navigation to your
own table markup (shadcn <Table>, plain HTML, Fluent <DataGrid>, anything).
This page is the condensed, one-screen reference. Each hook also has its own
page with a live demo on a plain <table>, full parameter and return tables,
and behavior notes: start at the Headless overview, or jump
to useHeadlessGrid,
useInlineEdit,
useRangeSelection,
useFillHandle,
useCellClipboard,
useUndoRedo,
useGridFocus, or
useGridVirtualization.
All seven hooks ship as React hooks:
import { useX } from '@alaarab/ogrid-react-radix'
Bundle-size tip — headless-only consumers
If your app only uses the hooks (no <OGrid> chrome), import from
@alaarab/ogrid-react instead of @alaarab/ogrid-react-radix. Same hooks,
no chrome CSS load (~40KB saved), and sideEffects: false lets your
bundler tree-shake aggressively:
import { useHeadlessGrid, useInlineEdit, useRangeSelection } from '@alaarab/ogrid-react';
Use @alaarab/ogrid-react-radix when you want both the headless hooks AND
the drop-in <OGrid> component.
useHeadlessGrid — data layer
The foundation. Returns sort/filter/paginate state plus the current page
of rows. Supports dataSource for server-side mode.
import { useHeadlessGrid } from '@alaarab/ogrid-react-radix';
const grid = useHeadlessGrid({
columns,
data: rows,
getRowId: (row) => row.id,
initialSort: { field: 'name', direction: 'asc' },
initialPageSize: 25,
});
Parameters
| Param | Type | Notes |
|---|
columns | IColumnDef<T>[] | Column definitions |
data | T[] | Full dataset (client-side) or empty array (server-side) |
getRowId | (row: T) => RowId | Stable row-identity extractor |
initialSort? | { field, direction } | Initial sort, omit for none |
initialFilters? | IFilters | Initial filter values |
initialPage? | number | Default 1 |
initialPageSize? | number | Default 25 |
sort?, filters?, page?, pageSize? | controlled overrides | Pass to put state under your control |
onSortChange?, onFiltersChange?, onPageChange?, onPageSizeChange? | callbacks | Fired when state changes |
dataSource? | IDataSource<T> | React only. Server-side mode |
workerSort? | boolean | 'auto' | React only. Worker-thread sort for large datasets |
Returns
| Field | Description |
|---|
rows | Current page rows after sort + filter |
allFilteredRows | Every filtered+sorted row across pages (client mode), or the current page (server mode) |
columns | Resolved columns |
totalCount | Post-filter total |
totalPages | Total pages at current page size |
sort, filters, page, pageSize | Current state |
setSort, toggleSort(columnId), sortIndicator(columnId) | Sort actions |
setFilters(filters), setFilter(key, value), hasActiveFilters | Filter actions |
setPage(n), setPageSize(n) | Pagination |
getRowId, getCellValue(row, columnId) | Row + cell helpers |
selectedRowIds, isRowSelected(row), toggleRowSelection(row), selectAllOnPage(), clearSelection() | Minimal Set-based row selection |
useInlineEdit — cell editing lifecycle
Manages start/commit/cancel for one cell at a time. Honors the column's
editable flag (boolean or per-row predicate) and validates new values
through valueParser — same flow <OGrid> uses internally.
const edit = useInlineEdit({
columns,
getRowId: (row) => row.id,
onCellEdit: ({ item, columnId, oldValue, newValue }) =>
updateRow(item.id, { [columnId]: newValue }),
});
const editor = edit.isEditing(row, col.columnId) ? edit.getEditorProps(row, col.columnId) : null;
<TableCell onDoubleClick={() => edit.startEdit(row, col.columnId)}>
{editor ? (
<input
autoFocus
value={String(editor.value ?? '')}
onChange={(e) => editor.onChange(e.target.value)}
onBlur={editor.onCommit}
onKeyDown={editor.onKeyDown}
/>
) : (
String(grid.getCellValue(row, col.columnId))
)}
</TableCell>
Parameters
| Param | Type |
|---|
columns | IColumnDef<T>[] |
getRowId | (row: T) => RowId |
onCellEdit | (event: { item, columnId, oldValue, newValue }) => void |
isCellEditable? | (row, columnId) => boolean (override per-row) |
Returns
| Field | Description |
|---|
editingCell | { rowId, columnId } | null |
pendingValue, setPendingValue | Buffered new value |
startEdit(row, columnId) | Begin editing (no-op if not editable) |
commitEdit() | Validate via valueParser, fire onCellEdit |
cancelEdit() | Close without firing |
isEditing(row, columnId) | boolean |
canEdit(row, columnId) | boolean |
getEditorProps(row, columnId) | { value, onChange(value), onCommit, onCancel, onKeyDown } — wire each to your input by hand (onChange takes the value, not an event); onKeyDown handles Enter (commit) and Escape (cancel) |
useRangeSelection — Excel-style range
Anchor + focus model. Foundation for useFillHandle and useCellClipboard.
const range = useRangeSelection({
rowCount: grid.rows.length,
colCount: grid.columns.length,
});
<TableCell
onMouseDown={(e) => e.shiftKey ? range.extendRange(rowIdx, colIdx) : range.startRange(rowIdx, colIdx)}
onMouseEnter={(e) => e.buttons === 1 && range.extendRange(rowIdx, colIdx)}
data-selected={range.isInRange(rowIdx, colIdx)}
/>
Parameters
| Param | Type |
|---|
rowCount | number (visible row count) |
colCount | number (visible column count) |
Returns
| Field | Description |
|---|
range | ISelectionRange | null (normalized rectangular bounds) |
anchor, focus | CellCoord | null |
startRange(row, col), extendRange(row, col), setRange(range), clearRange() | State actions |
selectAll() | Select every cell |
isInRange(row, col) | boolean |
getRangeRows(), getRangeCells() | Convenience extractors |
useFillHandle — drag-to-fill
Excel-style fill via core's applyFillValues. Type-compatibility checks
prevent dragging incompatible types (text into a number column).
const fill = useFillHandle({
rangeSelection: range,
rows: grid.rows,
columns,
onFillCells: (events) => events.forEach(applyEdit),
});
<div onMouseDown={fill.startFill} className="fill-handle-dot" />
<TableCell
onMouseEnter={() => fill.isFilling && fill.updateFill(rowIdx, colIdx)}
onMouseUp={fill.commitFill}
data-fill={fill.isInFillRange(rowIdx, colIdx)}
/>
Parameters
| Param | Type |
|---|
rangeSelection | UseRangeSelectionResult |
rows | T[] (current page rows) |
columns | IColumnDef<T>[] |
onFillCells | (events: ICellValueChangedEvent<T>[]) => void |
Returns
| Field | Description |
|---|
fillTarget | CellCoord | null (current drag target) |
isFilling | boolean |
fillRange | ISelectionRange | null (source range extended to target) |
startFill(), updateFill(row, col), commitFill(), cancelFill() | Lifecycle |
isInFillRange(row, col) | boolean for highlighting |
useCellClipboard — TSV copy/cut/paste
Round-trippable through Excel and Google Sheets. Honors clipboardFormatter
on copy and valueParser on paste validation.
const clipboard = useCellClipboard({
rangeSelection: range,
rows: grid.rows,
getRowId: (row) => row.id,
columns,
onCellEdit: (events) => events.forEach(applyEdit),
});
<div tabIndex={0} onCopy={clipboard.onCopy} onCut={clipboard.onCut} onPaste={clipboard.onPaste}>
{}
</div>
Parameters
| Param | Type |
|---|
rangeSelection | UseRangeSelectionResult |
rows | T[] |
getRowId? | (item: T) => RowId, keeps a pending cut attached to its source records when row objects are replaced or sorted; defaults to object identity |
columns | IColumnDef<T>[] |
onCellEdit | (events: ICellValueChangedEvent<T>[]) => void |
clipboard? | { readText, writeText } override (defaults to navigator.clipboard) |
onClipboardError? | (error: unknown) => void, called when a clipboard read/write fails (e.g. permission denied) |
A cut clears only source cells whose corresponding destination accepted the
paste. Read-only cells, rejected values, and cells beyond the grid boundary
leave their source values intact. Cells overwritten by an overlapping paste
keep their pasted values.
Returns
| Field | Description |
|---|
copyRange(), cutRange(), pasteRange() | Async actions for buttons and menus. They write and read through clipboard.writeText / readText and resolve (never reject) when the clipboard is unavailable or denied; see onClipboardError. |
onCopy(event), onCut(event) | Native copy / cut handlers for the grid container (CellClipboardCopyEvent). Put the range's TSV on event.clipboardData (works on plain http, no permission), prevent default, and mark the copy or cut range like copyRange() / cutRange(). Leave events aimed at inputs inside the container alone. |
onPaste(event) | Native paste handler for the grid container. Reads event.clipboardData (no permission prompt, works where readText is denied), prevents default, pastes like pasteRange(). Leaves pastes aimed at inputs inside the container alone. |
canPaste | boolean (feature detection for readText, so for pasteRange()). false until mounted, so server-rendered markup hydrates cleanly. |
activeCutRange, activeCopyRange | Marching-ants tracking |
clearClipboard() | Dismiss markers (bind to Escape) |
useUndoRedo — action history stack
Wraps your onCellEdit callback with an undo/redo history. Pair with
useInlineEdit / useFillHandle / useCellClipboard to get spreadsheet-
style undo across all of them for free.
const undo = useUndoRedo({ onCellValueChanged: applyEditToRows });
const commit = (e: InlineEditEvent<Row>) =>
undo.onCellValueChanged?.({ ...e, rowIndex: grid.rows.indexOf(e.item) });
const edit = useInlineEdit({ columns, getRowId, onCellEdit: commit });
const fill = useFillHandle({ ..., onFillCells: (events) => events.forEach(commit) });
<button onClick={undo.undo} disabled={!undo.canUndo}>Undo</button>
<button onClick={undo.redo} disabled={!undo.canRedo}>Redo</button>
Parameters
| Param | Type |
|---|
onCellValueChanged | (event: ICellValueChangedEvent<T>) => void |
maxUndoDepth? | number (default 100) |
Returns
| Field | Description |
|---|
onCellValueChanged | Wrapped callback — pass to other hooks instead of your raw handler |
undo(), redo() | Actions |
canUndo, canRedo | boolean flags |
beginBatch(), endBatch() | Group multiple edits as one undo step |
useGridFocus — keyboard navigation
Arrow / Tab / Enter / Home / End / PageUp / PageDown. Pairs with
useRangeSelection so Shift+Arrow extends the active range.
const focus = useGridFocus({
rowCount: grid.rows.length,
colCount: grid.columns.length,
rangeSelection: range,
});
<div tabIndex={0} onKeyDown={focus.getKeyDownHandler()}>
{}
</div>
Parameters
| Param | Type |
|---|
rowCount | number |
colCount | number |
pageSize? | number (PageUp/Down step, default 10) |
rangeSelection? | UseRangeSelectionResult (optional, for Shift+Arrow extend) |
Returns
| Field | Description |
|---|
activeCell, setActiveCell(cell) | Current focus + setter |
moveUp/Down/Left/Right(n?) | Programmatic movement |
moveToRowStart/RowEnd/Start/End() | Navigation helpers |
getCellProps(row, col) | Optional roving tabindex: { tabIndex, ref, onFocus, onBlur } for each cell, so the active cell holds DOM focus like <OGrid> (details) |
getKeyDownHandler() | Returns the handler to attach to your grid container's onKeyDown |
useGridVirtualization — windowed rendering
Headless row + column virtualization for large datasets. Tracks scroll
position on a consumer-owned container and reports which slice of rows/
columns to render with spacer offsets that preserve scroll geometry. Zero
external dependencies — uses core's pure compute helpers.
import { useRef } from 'react';
import { useHeadlessGrid, useGridVirtualization } from '@alaarab/ogrid-react';
const grid = useHeadlessGrid({ columns, data, getRowId, initialPageSize: 10000 });
const containerRef = useRef<HTMLDivElement>(null);
const virt = useGridVirtualization({
rowCount: grid.totalCount,
rowHeight: 36,
containerRef,
});
return (
<div ref={containerRef} onScroll={virt.onScroll} style={{ height: 400, overflow: 'auto' }}>
<div style={{ height: virt.totalHeight, position: 'relative' }}>
<div style={{ transform: `translateY(${virt.rowRange.offsetTop}px)` }}>
{grid.rows.slice(virt.rowRange.startIndex, virt.rowRange.endIndex + 1).map((row) => (
<div key={grid.getRowId(row)} style={{ height: 36 }}>
{}
</div>
))}
</div>
</div>
</div>
);
Parameters
| Param | Type | Notes |
|---|
rowCount | number | Total rows in the data set. |
rowHeight | number | Uniform row height in pixels. |
containerRef | RefObject<HTMLElement | null> | The scroll container — must have a fixed height + overflow: auto. |
overscan | number | Extra rows above/below the visible window. Default 5. |
enabled | boolean | Disable virtualization entirely. Default true. |
threshold | number | Below this row count, all rows render (avoids small-dataset artifacts). Default 100. |
columnWidths | number[] | Per-column widths (unpinned only) — enables column virt. Omit for row-only. |
columnOverscan | number | Extra columns left/right of the viewport. Default 2. |
Returns
| Field | Type | Notes |
|---|
totalHeight | number | Total scroll height (rowCount × rowHeight). |
rowRange | { startIndex; endIndex; offsetTop; offsetBottom } | Visible row slice + spacer pixels. |
columnRange | { startIndex; endIndex; leftOffset; rightOffset } | null | Visible column slice (null when columnWidths omitted). |
scrollToIndex | (index, align?) => void | Programmatically scroll a row into view ('start' | 'center' | 'end'). |
onScroll | () => void | Attach to the container's onScroll prop. |
isActive | boolean | True when virtualization is engaged (above threshold + enabled). |
All seven combined
The canonical demo combining every hook on a plain HTML table is the
SpreadsheetDemo Storybook story —
~200 lines you can copy as a starter template.
const grid = useHeadlessGrid({ columns, data, getRowId: (r) => r.id });
const range = useRangeSelection({ rowCount: grid.rows.length, colCount: grid.columns.length });
const undo = useUndoRedo({ onCellValueChanged: applyEditToRows });
const commit = (e: InlineEditEvent<Row>) =>
undo.onCellValueChanged?.({ ...e, rowIndex: grid.rows.indexOf(e.item) });
const edit = useInlineEdit({ columns, getRowId, onCellEdit: commit });
const fill = useFillHandle({ rangeSelection: range, rows: grid.rows, columns, onFillCells: (events) => events.forEach(commit) });
const clipboard = useCellClipboard({ rangeSelection: range, rows: grid.rows, columns, onCellEdit: commit });
const focus = useGridFocus({ rowCount: grid.rows.length, colCount: grid.columns.length, rangeSelection: range });
See also