API Surface & DOM Access
Depends on: Philosophy, State Model
Hook Hierarchy
useGridBehavior(options) Main hook, complete setup
│
│ returns grid instance
▼
┌──────┴──────────────────────────┐
│ │
├─ useCellBehavior(grid, ...) Reactive cell state + props
├─ useHeaderBehavior(grid, ...) Reactive header state + props
├─ useRowBehavior(grid, ...) Reactive row state + props
│
├─ useGridSelector(grid, sel) Subscribe to global state
└─ useCellSelector(grid, cell) Subscribe to cell state, O(1)useGridBehavior
Main hook. Creates store, manages transitions, executes effects.
Input
| Option | Type | Description |
|---|---|---|
rows | { id: string }[] | Row objects in visual order (e.g., TanStack rows) |
columns | { id: string }[] | Column objects in visual order (e.g., TanStack columns) |
isEditable | (cell) => boolean | Can cell enter edit mode |
config | GridConfig | Keybindings, a11y settings |
initialState | Partial<GridState> | Override initial state (optional) |
Lifecycle Hooks (Input)
| Hook | Signature | Purpose |
|---|---|---|
onBefore | (action) => boolean | Block action if false |
onAfter | (action, state) => void | React post-transition |
onEffect | (effect) => boolean | Intercept effect, true = handled |
Callbacks (Input)
| Callback | Signature | When |
|---|---|---|
onCommit | (cell, value, original) => void | Edit committed |
onPaste | (startCell, data) => void | Paste processed |
onDelete | (cells) => void | Delete requested |
onSelectionChange | (ranges) => void | Selection changed |
Output
| Property | Type | Description |
|---|---|---|
store | GridStore | Zustand store with dispatch and getContext |
registries | Registries | Non-reactive registries (cell types, refs) |
props | GridProps | Spreadable props for grid container |
GridStore methods:
| Method | Type | Description |
|---|---|---|
(selector) | T | Subscribe to state with Zustand |
dispatch | (action) => void | Triggers state transition |
getContext | () => GridContext | Get current context (derived rowIds, colIds, config) |
getState | () => GridState | Get current state snapshot |
Props Getters
getGridProps()
| Prop | Value | Purpose |
|---|---|---|
ref | callback | Registry grid element |
role | 'grid' | ARIA |
aria-rowcount | rowCount | ARIA |
aria-colcount | colCount | ARIA |
onKeyDown | handler | Global navigation |
getCellProps(rowId, colId)
Returns static props only. For reactive state (tabIndex, aria-selected), use useCellBehavior.
| Prop | Value | Purpose |
|---|---|---|
ref | callback | Registry cell element |
role | 'gridcell' | ARIA |
data-row-id | rowId | Debug, CSS |
data-col-id | colId | Debug, CSS |
onClick | handler | Focus, select |
onDoubleClick | handler | Edit |
onKeyDown | handler | Cell shortcuts |
onFocus | handler | Focus sync |
getHeaderProps(colId)
Returns static props only. For reactive state (tabIndex), use useHeaderBehavior.
| Prop | Value | Purpose |
|---|---|---|
ref | callback | Registry header element |
role | 'columnheader' | ARIA |
data-col-id | colId | Debug, CSS |
onClick | handler | Focus header |
onKeyDown | handler | Keyboard navigation |
getRowProps(rowId)
| Prop | Value | Purpose |
|---|---|---|
role | 'row' | ARIA |
aria-rowindex | index + 1 | ARIA (1-based) |
Selectors
useGridSelector(grid, selector)
Subscribe to global state. Re-renders when selector result changes (shallow equal).
| Typical use | Selector |
|---|---|
| In edit mode? | s => s.focus.mode === 'edit' |
| In interactive mode? | s => s.focus.mode === 'interactive' |
| Selection count | s => s.selection.ranges.length |
| Focus mode | s => s.focus.mode |
| Current draft | s => s.draft |
| Has focus? | s => s.focus.target !== null |
useCellSelector(grid, cellRef, selector)
Subscribe to state relative to single cell. Internal registry maps cellKey → subscribers. Focus change notifies only involved cells (old/new), O(1).
| Typical use | Selector |
|---|---|
| Is focused? | 'focused' or c => c.focused |
| Is selected? | 'selected' or c => c.selected |
| Is editing? | 'editing' or c => c.editing |
| Is in interactive? | 'interactive' or c => c.interactive |
String selectors are shorthand for common cases.
Cell state object:
type CellState = {
focused: boolean; // focus.target points to this cell
selected: boolean; // cell is in selection.ranges
editing: boolean; // focused AND mode === 'edit'
interactive: boolean; // focused AND mode === 'interactive'
};Behavior Hooks (Reactive Mode)
Form library style hooks that return spreadable props and reactive state.
useCellBehavior(grid, rowId, colId, options?)
Returns everything needed for a cell: props, state, and (for composite cells) interactive element registration.
Cell Types:
| Type | Option | Description | Returns |
|---|---|---|---|
| Display | (none) | Read-only, no special interaction | DisplayCellBehavior |
| Editable | { type: 'editable' } | Enter/F2/typing enters edit mode | EditableCellBehavior |
| Composite | { type: 'composite' } | Tab enters interactive mode for internal focusables | CompositeCellBehavior |
// Display cell (default)
const cell = useCellBehavior(grid, rowId, colId);
// Editable cell
const cell = useCellBehavior(grid, rowId, colId, { type: "editable" });
// Composite cell (has focusable children)
const cell = useCellBehavior(grid, rowId, colId, { type: "composite" });
<div
{...cell.props} // Spread all props
className={cn(
cell.isFocused && "ring-2",
cell.isSelected && "bg-blue-50",
cell.isBorderTop && "border-t-2",
)}
>
{/* Composite cells use interactive() to register focusables */}
<button {...cell.interactive("save")}>Save</button>
<button {...cell.interactive("delete")}>Delete</button>
</div>;Returns:
| Property | Type | Description |
|---|---|---|
props | CellBehaviorProps | Spreadable props (ref, role, tabIndex, aria-*, handlers) |
isFocused | boolean | Cell has focus |
isSelected | boolean | Cell is in selection |
isEditing | boolean | Cell is being edited |
isInteractive | boolean | Cell is in interactive mode |
isBorderTop/Bottom/Left/Right | boolean | Selection border edges |
interactive(id, options?) | () => InteractiveProps | Register interactive focusables |
interactive() for interactive mode:
// Register internal focusable elements
<button {...cell.interactive('edit')}>Edit</button>
<button {...cell.interactive('delete', { tabIndex: 0 })}>Delete</button>Elements registered with interactive():
- Get
tabIndex: -1in navigation mode (not tabbable) - Get specified
tabIndex(default: 0) in interactive mode
useHeaderBehavior(grid, colId)
Same pattern for headers.
const header = useHeaderBehavior(grid, colId);
<div {...header.props} className={cn(header.isFocused && "ring-2")}>
{children}
<button {...header.interactive("sort")}>Sort</button>
</div>;useRowBehavior(grid, rowId)
Provides row props. Unlike useCellBehavior/useHeaderBehavior, this hook does NOT subscribe to the Zustand store. Rows are layout containers — their props are static and derive only from context. This prevents unnecessary re-renders when focus/selection changes.
const row = useRowBehavior(grid, rowId);
<div {...row.props}>{cells}</div>;Returns:
| Property | Type | Description |
|---|---|---|
props | RowBehaviorProps | Spreadable props (role, aria-rowindex) |
DOM Access
Refs Registry
Registry = {
grid: HTMLElement | null
cells: Map<`${rowId}:${colId}`, HTMLElement>
headers: Map<colId, HTMLElement>
liveRegion: HTMLElement | null
}Population
Automatic via ref callbacks in props getters:
- Mount: ref callback receives element → inserted in Map
- Unmount: ref callback receives null → removed from Map
Lookup (for effects)
| Method | Return |
|---|---|
refs.getCell(rowId, colId) | HTMLElement | null |
refs.getHeader(colId) | HTMLElement | null |
refs.getGrid() | HTMLElement | null |
refs.getLiveRegion() | HTMLElement | null |
Virtualization
Cells outside viewport: unmounted → removed from registry. Effects on cells not in registry: no-op. Scroll effect must bring cell into view before focus.
Intercept SCROLL_INTO_VIEW to use virtualizer:
onEffect: (effect) => {
if (effect.type === "SCROLL_INTO_VIEW") {
virtualizer.scrollToIndex(getRowIndex(effect.cell.rowId));
return true;
}
return false;
};ARIA Live Region
Rendered internally (hidden), used for screen reader announcements.
<div aria-live="polite" aria-atomic="true" style="visually-hidden" />ANNOUNCE effect updates textContent. 50ms timeout to ensure screen reader picks it up.
Internal Store (Zustand)
Why Zustand
| Pro | Con |
|---|---|
| Granular selectors out-of-box | Additional dependency |
| No Context provider (performance) | Less familiar API than useState |
| O(1) subscriptions per cell | — |
| Middleware ecosystem (devtools, persist) | — |
Alternatives considered:
useState+ Context: cascade re-render on every changeuseReducer+ Context: same problem- Valtio: proxy magic can confuse, less control
- Jotai: good but atoms pattern less suited to structured state
Architecture
useGridBehavior
│
├─► creates Zustand store (internal state)
│
├─► useGridSelector uses store.subscribe + selector
│
└─► useCellSelector uses store.subscribe + cell-specific selector
with internal registry for O(1) notifyStore is source of truth. Userland reads via selectors, modifies via dispatch.
Extensibility
Lifecycle Hooks
Generic, operate on any action/effect.
| Hook | Signature | Moment | Return |
|---|---|---|---|
onBefore | (action) => boolean | Pre-transition | false = cancel |
onAfter | (action, newState) => void | Post-transition | — |
onEffect | (effect) => boolean | Pre-effect execution | true = handled, skip default |
Why Generic (not specific)
| Approach | Pro | Con |
|---|---|---|
Generic (onBefore) | 1 hook for everything, automatically extensible | Userland switches on action.type |
Specific (onBeforeEdit, onBeforeDelete, ...) | Type-safe, autocomplete | N hooks to implement/maintain |
Choice: generic. Trivial implementation, new actions automatically supported. Type narrowing on action.type still gives type safety.
Flow with Hooks
dispatch(action)
│
▼
onBefore(action) ──false──► STOP
│ true
▼
transition(state, action, ctx) → { state, effects }
│
▼
setState(newState)
│
▼
onAfter(action, newState)
│
▼
for each effect:
onEffect(effect) ──true──► skip default
│ false
▼
executeDefault(effect)Config
| Option | Type | Default | Description |
|---|---|---|---|
keyBindings | Partial<KeyBindings> | defaults | Override shortcuts |
a11y.announceNavigation | boolean | true | Announce focus changes |
a11y.announceSelection | boolean | true | Announce selection changes |
editing.commitOnBlur | boolean | true | Commit when focus leaves cell |
editing.commitOnEnter | boolean | true | Commit on Enter |
selection.mode | 'single' | 'range' | 'range' | Selection mode |
Default KeyBindings
| Action | Default | Configurable |
|---|---|---|
| Navigate | Arrow keys | Yes |
| Select extend | Shift + Arrow | Yes |
| Select all | Ctrl/Cmd + A | Yes |
| Edit | Enter, F2 | Yes |
| Commit | Enter | Yes |
| Cancel | Escape | Yes |
| Copy | Ctrl/Cmd + C | Yes |
| Paste | Ctrl/Cmd + V | Yes |
| Delete | Delete, Backspace | Yes |
Event Composition
composeEventHandlers
Utility for composing userland handlers with library handlers. Follows Radix UI pattern.
composeEventHandlers<E extends SyntheticEvent>(
originalHandler: ((event: E) => void) | undefined,
userHandler: ((event: E) => void) | undefined,
options?: { checkForDefaultPrevented?: boolean }
): (event: E) => voidBehavior:
- Original handler (library) runs first
- If original handler calls
preventDefault(), user handler is skipped - Otherwise, user handler runs
Usage:
import { composeEventHandlers } from "@ts-zen/react-datagrid";
const headerProps = grid.getHeaderProps(colId);
<div
{...headerProps}
onClick={composeEventHandlers(
headerProps.onClick,
(e) => {
// Add custom behavior: select column on header click
grid.store.dispatch({ type: "SELECT_COLUMN", colId });
}
)}
/>When to use:
- Adding behavior to props without losing library functionality
- Extending header/cell click handlers
- Composing keyboard handlers
Virtualization
Hook knows nothing about virtualization. Support via pattern, not implementation.
Without Virtualization
- All cells in DOM
SCROLL_INTO_VIEWuseselement.scrollIntoView({ block: 'nearest' })- Browser handles everything
With Virtualization
Cells outside viewport don't exist in DOM. Userland intercepts effect:
onEffect: (effect) => {
if (effect.type === "SCROLL_INTO_VIEW") {
const rowIndex = rowIds.indexOf(effect.cell.rowId);
virtualizer.scrollToIndex(rowIndex, { align: "auto" });
return true; // handled
}
return false;
};Focus on Virtualized Cells
| Scenario | Behavior |
|---|---|
| Focus on cell in viewport | FOCUS_ELEMENT finds ref, focus ok |
| Focus on cell outside viewport | Ref null, focus silently ignored |
| With scroll intercept | Userland scrolls first, focus after render |
Complex timing (scroll → render → focus) is userland's responsibility. Intentional approach: every virtualizer has different API.
Complete ARIA Reference
Roles
| Element | Role | Notes |
|---|---|---|
| Grid container | role="grid" | Required |
| Row element | role="row" | Required |
| Data cell | role="gridcell" | Required |
| Column header | role="columnheader" | Required for headers |
| Row header | role="rowheader" | Optional, v1 out of scope |
Properties and States
| Attribute | Element | When Set | Value |
|---|---|---|---|
aria-label | Grid | Always (or labelledby) | Descriptive label |
aria-labelledby | Grid | When external label | ID of labeling element |
aria-describedby | Grid | When description exists | ID of description element |
aria-rowcount | Grid | When virtualized | Total row count |
aria-colcount | Grid | When virtualized | Total column count |
aria-multiselectable | Grid | When range selection | "true" |
aria-rowindex | Row | When virtualized | 1-based row position |
aria-colindex | Cell | When virtualized | 1-based column position |
aria-selected | Cell | When selection enabled | "true" or "false" |
aria-readonly | Cell | When editing enabled | "true" or "false" |
aria-invalid | Cell | During validation error | "true" |
aria-sort | Header | When column sortable | "ascending", "descending", "none" |
aria-colspan | Cell | When cell spans columns | Number (non-table grids) |
aria-rowspan | Cell | When cell spans rows | Number (non-table grids) |
aria-busy | Grid/Cell | During async operation | "true" (userland sets) |
tabindex Pattern (Roving)
| Element State | tabindex | Purpose |
|---|---|---|
| Focused cell | 0 | In tab sequence |
| Other cells | -1 | Navigable via arrow keys |
| Grid (no focus) | 0 | Grid enters tab sequence |
| Grid (has focus) | -1 | Focus is on cell, not grid |
Virtualization ARIA
When rows/columns are virtualized, userland must set indices:
// Row element
<div role="row" aria-rowindex={actualRowIndex + 1}>
// Cell element
<div role="gridcell" aria-colindex={actualColIndex + 1}>Important: Indices are 1-based per ARIA spec.
Announcements
Default Announcement Messages
The library announces state changes via ARIA live region.
| Event | Default Message | Configurable |
|---|---|---|
| Focus cell | "{content}", row {n} of {total}, column {name}" | Yes |
| Focus header | "Column {name}" | Yes |
| Enter edit mode | "Editing" | Yes |
| Exit edit mode (commit) | "Saved" | Yes |
| Exit edit mode (cancel) | "Cancelled" | Yes |
| Selection (single) | "Selected" | Yes |
| Selection (range) | "{n} cells selected" | Yes |
| Selection cleared | "Selection cleared" | Yes |
| Validation error | "Error: {message}" | Yes |
| Copy | "{n} cells copied" | Yes |
| Paste | "Pasted" | Yes |
Configuration
useGridBehavior({
// ...
config: {
a11y: {
announceNavigation: true, // Announce focus changes
announceSelection: true, // Announce selection changes
announceEditing: true, // Announce edit mode changes
messages: {
focusCell: (cell, content) => `${content}, row ${cell.rowIndex}`,
enterEdit: () => "Editing cell",
// ... custom message overrides
},
},
},
});Throttling
Rapid announcements (e.g., holding arrow key) are throttled to prevent overwhelming the screen reader:
| Scenario | Behavior |
|---|---|
| Single navigation | Announce immediately |
| Rapid navigation | Announce only final position |
| Debounce delay | 150ms (configurable) |
| Critical announcements | Never throttled (errors, commits) |