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).

Per-hook guides

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:

// 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
allFilteredRowsEvery filtered+sorted row across pages (client mode), or the 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 (per cell). getEditorProps returns hook callbacks, not DOM props:
// `onChange` takes the value itself, so wire the fields by hand.
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​

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){ 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,
});

// 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,
getRowId: (row) => row.id,
columns,
onCellEdit: (events) => events.forEach(applyEdit),
});

// Ctrl/Cmd+C, X and V are left to the browser: attach the native clipboard
// event handlers to the focusable grid container.
<div tabIndex={0} onCopy={clipboard.onCopy} onCut={clipboard.onCut} onPaste={clipboard.onPaste}>
{/* cells */}
</div>

Parameters​

ParamType
rangeSelectionUseRangeSelectionResult
rowsT[]
getRowId?(item: T) => RowId, keeps a pending cut attached to its source records when row objects are replaced or sorted; defaults to object identity
columnsIColumnDef<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​

FieldDescription
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.
canPasteboolean (feature detection for readText, so for pasteRange()). false until mounted, so server-rendered markup hydrates cleanly.
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 });

// useUndoRedo speaks ICellValueChangedEvent (needs rowIndex); useInlineEdit emits
// InlineEditEvent (no rowIndex), so add the index of the row in the current page.
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​

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
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 }}>
{/* 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 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​