Skip to main content

Headless hooks

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:

// Headless-only — skip the radix package entirely
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

ParamTypeNotes
columnsIColumnDef<T>[]Column definitions
dataT[]Full dataset (client-side) or empty array (server-side)
getRowId(row: T) => RowIdStable row-identity extractor
initialSort?{ field, direction }Initial sort, omit for none
initialFilters?IFiltersInitial filter values
initialPage?numberDefault 1
initialPageSize?numberDefault 25
sort?, filters?, page?, pageSize?controlled overridesPass to put state under your control
onSortChange?, onFiltersChange?, onPageChange?, onPageSizeChange?callbacksFired when state changes
dataSource?IDataSource<T>React only. Server-side mode
workerSort?boolean | 'auto'React only. Worker-thread sort for large datasets

Returns

FieldDescription
rowsCurrent page rows after sort + filter
allFilteredRowsFull filtered+sorted set (client mode) or current page (server mode)
columnsResolved columns
totalCountPost-filter total
totalPagesTotal pages at current page size
sort, filters, page, pageSizeCurrent state
setSort, toggleSort(columnId), sortIndicator(columnId)Sort actions
setFilters(filters), setFilter(key, value), hasActiveFiltersFilter 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 }),
});

// In render:
<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

ParamType
columnsIColumnDef<T>[]
getRowId(row: T) => RowId
onCellEdit(event: { item, columnId, oldValue, newValue }) => void
isCellEditable?(row, columnId) => boolean (override per-row)

Returns

FieldDescription
editingCell{ rowId, columnId } | null
pendingValue, setPendingValueBuffered 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,
});

// Click + drag:
<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

ParamType
rowCountnumber (visible row count)
colCountnumber (visible column count)

Returns

FieldDescription
rangeISelectionRange | null (normalized rectangular bounds)
anchor, focusCellCoord | 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),
});

// Bottom-right of active range:
<div onMouseDown={fill.startFill} className="fill-handle-dot" />

// On every cell during drag:
<TableCell
onMouseEnter={() => fill.isFilling && fill.updateFill(rowIdx, colIdx)}
onMouseUp={fill.commitFill}
data-fill={fill.isInFillRange(rowIdx, colIdx)}
/>

Parameters

ParamType
rangeSelectionUseRangeSelectionResult
rowsT[] (current page rows)
columnsIColumnDef<T>[]
onFillCells(events: ICellValueChangedEvent<T>[]) => void

Returns

FieldDescription
fillTargetCellCoord | null (current drag target)
isFillingboolean
fillRangeISelectionRange | 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

ParamType
rangeSelectionUseRangeSelectionResult
rowsT[]
columnsIColumnDef<T>[]
onCellEdit(events: ICellValueChangedEvent<T>[]) => void
clipboard?{ readText, writeText } override (defaults to navigator.clipboard)

Returns

FieldDescription
copyRange(), cutRange(), pasteRange()Async actions
canPasteboolean (feature detection)
activeCutRange, activeCopyRangeMarching-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

ParamType
onCellValueChanged(event: ICellValueChangedEvent<T>) => void
maxUndoDepth?number (default 100)

Returns

FieldDescription
onCellValueChangedWrapped callback — pass to other hooks instead of your raw handler
undo(), redo()Actions
canUndo, canRedoboolean 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, // optional — enables Shift+Arrow extend
});

<div tabIndex={0} onKeyDown={focus.getKeyDownHandler()}>
{/* Render cells; highlight focus.activeCell */}
</div>

Parameters

ParamType
rowCountnumber
colCountnumber
pageSize?number (PageUp/Down step, default 10)
rangeSelection?UseRangeSelectionResult (optional, for Shift+Arrow extend)

Returns

FieldDescription
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 }}>
{/* render row */}
</div>
))}
</div>
</div>
</div>
);

Parameters

ParamTypeNotes
rowCountnumberTotal rows in the data set.
rowHeightnumberUniform row height in pixels.
containerRefRefObject<HTMLElement | null>The scroll container — must have a fixed height + overflow: auto.
overscannumberExtra rows above/below the visible window. Default 5.
enabledbooleanDisable virtualization entirely. Default true.
thresholdnumberBelow this row count, all rows render (avoids small-dataset artifacts). Default 100.
columnWidthsnumber[]Per-column widths (unpinned only) — enables column virt. Omit for row-only.
columnOverscannumberExtra columns left/right of the viewport. Default 2.

Returns

FieldTypeNotes
totalHeightnumberTotal scroll height (rowCount × rowHeight).
rowRange{ startIndex; endIndex; offsetTop; offsetBottom }Visible row slice + spacer pixels.
columnRange{ startIndex; endIndex; leftOffset; rightOffset } | nullVisible column slice (null when columnWidths omitted).
scrollToIndex(index, align?) => voidProgrammatically scroll a row into view ('start' | 'center' | 'end').
onScroll() => voidAttach to the container's onScroll prop.
isActivebooleanTrue 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