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).
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 | Full filtered+sorted set (client mode) or 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 }),
});
<TableCell onDoubleClick={() => edit.startEdit(row, col.columnId)}>
{edit.isEditing(row, col.columnId) ? (
<input autoFocus {...edit.getEditorProps(row, col.columnId)} />
) : (
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) | Spread onto your input — { value, onChange, onBlur, onKeyDown } (Enter commits, Escape cancels) |
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,
columns,
onCellEdit: (events) => events.forEach(applyEdit),
});
useEffect(() => {
const handler = (e) => {
const mod = e.metaKey || e.ctrlKey;
if (mod && e.key === 'c') clipboard.copyRange();
if (mod && e.key === 'x') clipboard.cutRange();
if (mod && e.key === 'v') clipboard.pasteRange();
};
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
}, [clipboard]);
Parameters
| Param | Type |
|---|
rangeSelection | UseRangeSelectionResult |
rows | T[] |
columns | IColumnDef<T>[] |
onCellEdit | (events: ICellValueChangedEvent<T>[]) => void |
clipboard? | { readText, writeText } override (defaults to navigator.clipboard) |
Returns
| Field | Description |
|---|
copyRange(), cutRange(), pasteRange() | Async actions |
canPaste | boolean (feature detection) |
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 edit = useInlineEdit({ columns, getRowId, onCellEdit: undo.onCellValueChanged });
const fill = useFillHandle({ ..., onFillCells: (events) => events.forEach(undo.onCellValueChanged) });
<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 |
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 edit = useInlineEdit({ columns, getRowId, onCellEdit: undo.onCellValueChanged });
const fill = useFillHandle({ rangeSelection: range, rows: grid.rows, columns, onFillCells });
const clipboard = useCellClipboard({ rangeSelection: range, rows: grid.rows, columns, onCellEdit });
const focus = useGridFocus({ rowCount: grid.rows.length, colCount: grid.columns.length, rangeSelection: range });
See also