React Aria Hooks for Custom Grids

Permalink to "React Aria Hooks for Custom Grids"

React Aria comes in two layers: React Aria Components, ready-made unstyled components, and the lower-level hooks they are built on (useTable, useTableRow, useTableCell, useGrid, useGridCell, plus state from React Stately). The hooks give you the same keyboard behaviour, focus management, selection logic and ARIA attributes as the components, while letting you render any markup — useful for highly custom grids: unusual layouts, custom virtualization, integration with canvas or exotic cell editors.

This guide belongs to headless UI primitives for data.

Spec reference

Permalink to "Spec reference"
  • React Stately — useTableState (collection, selection, sorting) or useGridState.
  • React Aria hooks — useTable returns gridProps for the container; useTableRow, useTableCell, useTableColumnHeader, useTableSelectionCheckbox for children; keyboard navigation via a keyboard delegate.
  • ARIA grid pattern — roles, aria-rowcount, aria-rowindex, aria-sort, aria-selected applied by the hooks.
  • useFocusRing — exposes isFocusVisible for styling.
  • Relevant criteria: SC 2.1.1, 4.1.2, 1.3.1 (A), SC 2.4.7, 4.1.3 (AA).
React Aria hooks architecture Layers: React Stately state (collection, selection, sort); React Aria hooks producing props and behaviour; your markup spreading those props; your styles using focus and selection state; your announcer for results. React Aria hooks architectureStately statecollection, selection, sorting, focus keyAria hooksgrid, row, cell, header, checkbox propsYour markupany elements, spreading propsYour stylesisFocusVisible, isSelectedYour announcerresult counts and actions
Hooks guarantee behaviour; markup and messages are yours.

When to use — and when not to

Permalink to "When to use — and when not to"

Use the hooks when React Aria Components’ markup or rendering model does not fit — custom virtualization, unusual cell layouts, mixing with canvas, or integrating into an existing grid’s DOM.

Prefer React Aria Components when they fit; they encode the same behaviour with less code. Prefer a full grid library when you need enterprise features (grouping, pivoting, Excel export) rather than custom rendering.

The misapplication: using the hooks but forgetting to spread props on one level (rows or cells), which breaks keyboard navigation or roles silently.

Components versus hooks Comparison: React Aria Components give behaviour and structure with less code; hooks give the same behaviour with full markup control at the cost of wiring every level. Components versus hooks✗ React Aria ComponentsBehaviour and structure providedLess codeStyling via data attributesFits most tablesFewer ways to break✓ React Aria hooksBehaviour provided; structure yoursMore codeStyling via returned stateFits unusual renderingEvery level must be wired

Annotated code example

Permalink to "Annotated code example"
import { useTable, useTableRowGroup, useTableHeaderRow, useTableColumnHeader, useTableRow, useTableCell, useFocusRing, mergeProps } from 'react-aria';
import { useTableState } from 'react-stately';

function InvoiceGrid(props) {
  const state = useTableState({ ...props, selectionMode: 'multiple', showSelectionCheckboxes: false });
  const ref = useRef(null);
  const { gridProps } = useTable({ 'aria-label': 'Invoices' }, state, ref);          // grid role, keyboard (SC 2.1.1)
  const { collection } = state;
  return (
    <table {...gridProps} ref={ref} className="grid">
      <RowGroup type="thead">
        {collection.headerRows.map((hr) => (
          <HeaderRow key={hr.key} item={hr} state={state}>
            {[...hr.childNodes].map((col) => <ColumnHeader key={col.key} column={col} state={state} />)}
          </HeaderRow>
        ))}
      </RowGroup>
      <RowGroup type="tbody">
        {[...collection.body.childNodes].map((row) => (
          <Row key={row.key} item={row} state={state}>
            {[...row.childNodes].map((cell) => <Cell key={cell.key} cell={cell} state={state} />)}
          </Row>
        ))}
      </RowGroup>
    </table>
  );
}

function Cell({ cell, state }) {
  const ref = useRef(null);
  const { gridCellProps } = useTableCell({ node: cell }, state, ref);             // gridcell role, focus
  const { isFocusVisible, focusProps } = useFocusRing();
  return <td {...mergeProps(gridCellProps, focusProps)} ref={ref}
             className={isFocusVisible ? 'cell focus-visible' : 'cell'}>{cell.rendered}</td>;   // SC 2.4.7
}
// RowGroup, HeaderRow, ColumnHeader and Row follow the same pattern with their hooks.

Keyboard & AT behaviour

Permalink to "Keyboard & AT behaviour"
Key Behaviour (from hooks) Expected announcement
Tab Into grid Grid name, cell
Arrows Cell or row navigation Header on change, cell
Enter on sortable header Sort aria-sort state
Space Toggle row selection Selection state
Ctrl/Cmd+A Select all Selection state
Typing Typeahead Matched row
What each hook level provides Matrix of hook levels — useTable, row group hooks, header row and column header hooks, useTableRow, useTableCell, useFocusRing — against roles, keyboard and state. What each hook level providesRolesKeyboardStateuseTablegridDelegateFocus keyRow groupsrowgroupNoNoColumn headerscolumnheaderSort activationaria-sortuseTableRowrowSelectionaria-selecteduseTableCellgridcellFocusNouseFocusRingNoNoFocus visible
Every level must be wired; skipping one breaks the whole.

Integration context

Permalink to "Integration context"

React Aria Components, built on these hooks, are covered in React Aria Components Table and GridList. Composite focus without grid semantics is in Ariakit Composite for custom grids. The grid pattern itself is described in composite widget roles & states.

Gotchas

Permalink to "Gotchas"

Every level matters. Missing row group or header row hooks produce invalid structure; test with role queries.

Virtualization. Use React Aria’s virtualizer or ensure your own sets aria-rowcount and aria-rowindex; the hooks expect collection indices.

Version alignment. Keep react-aria and react-stately versions aligned; mismatches cause subtle bugs.

Worked example

Permalink to "Worked example"

A data platform needed a grid that renders cells on a canvas for sparkline-heavy rows but keeps accessible structure. Using React Aria hooks, the team rendered a semantic grid structure in the DOM with all props spread (roles, keyboard, selection), drew sparklines on per-cell canvases with text alternatives in each cell, and styled focus with useFocusRing. Keyboard and screen reader behaviour matched React Aria Components exactly, while the visual layer stayed custom.

Testing it with a screen reader

Permalink to "Testing it with a screen reader"
  1. Tab into the grid; confirm the name and first cell.
  2. Navigate with arrows and table commands; confirm headers and row positions.
  3. Sort by a header; confirm aria-sort and your result message.
  4. Select rows with Space and Shift+arrows; confirm selection announcements.
  5. Run the same checks on the equivalent React Aria Components table to compare behaviour.

Wrapping it for product teams

Permalink to "Wrapping it for product teams"

Keep hook wiring inside two or three internal components (GridShell, GridRow, GridCell) and expose a declarative API for columns and rows. Teams then build custom grids without touching hooks, and maintainers update wiring in one place when React Aria changes.

Common mistakes

Permalink to "Common mistakes"
  • Skipping hooks at one level.
  • No focus-visible styling.
  • Mismatched react-aria and react-stately versions.
  • Expecting the hooks to announce result counts.

Adoption checklist

Permalink to "Adoption checklist"
  1. Start from React Aria Components; drop to hooks only where needed.
  2. Wrap hook wiring in internal components so product teams never touch it.
  3. Test structure with role queries and ARIA snapshots.
  4. Style focus and selection from tokens.
  5. Add announcements for filtering and loading.

FAQ

Permalink to "FAQ"
When should I use hooks instead of components?

When you need markup or rendering control that components do not allow, such as custom virtualization or canvas content.

Do hooks provide aria-sort?

Yes, through column header hooks when sorting is configured.

Can I use the hooks with a div-based layout?

Yes, as long as roles come from the hooks; they set grid, row and cell roles on whatever elements you render.

Do hooks support drag and drop?

React Aria has drag-and-drop hooks with keyboard and screen reader support; integrate them with your grid if needed.

Are hooks harder to maintain?

They require more code; isolate them in a few internal components.

Do they handle RTL?

Yes, through the locale provider; arrow keys follow reading direction.

How do I test hook-based grids?

Role queries, keyboard events and ARIA snapshots, plus manual screen reader checks.

Can I combine the hooks with a third-party virtualizer?

Yes, if you keep collection indices aligned and set aria-rowcount and aria-rowindex; React Aria’s own virtualizer is simpler to integrate.

Do hooks support column resizing?

React Aria provides column resizing hooks with keyboard support; integrate them for resizable columns.

Where do I put cell editors?

Render them inside cells and manage edit mode in your state; follow the edit lifecycle (Enter or F2 to edit, Escape to cancel) and return focus to the cell.

Permalink to "Related"

← Back to Headless UI Primitives for Data