Building an Accessible Sortable Table in React

Permalink to "Building an Accessible Sortable Table in React"

This guide builds a sortable table as a React component that keeps its accessibility tree in step with its render output: aria-sort on the <th>, a polite live region that confirms each sort, keyboard activation on the header button, and focus preserved on that button after the rows re-order. The single failure it prevents is the silent sort — a React table where clicking a header visually reorders rows while the screen reader announces nothing and focus is lost to document.body. It is a React-specific companion to the sortable and filterable data grids reference and the attribute-level aria-sort attributes for accessible column filtering deep-dive.


Spec reference

Permalink to "Spec reference"

The component leans on three normative behaviours:

  • aria-sort (ARIA 1.2) on <th scope="col">, taking none, ascending, descending, or other. Exactly one column carries a directional value at a time; every other sortable header carries none.
  • role="status" — a polite live region (implicit aria-live="polite") that announces the new sort without moving focus, satisfying WCAG 4.1.3 Status Messages (Level AA).
  • Focus preservation across the DOM reorder, satisfying WCAG 3.2.2 On Input (Level A) — activating a sort must not throw the user to an unexpected place — and 2.1.1 Keyboard (Level A).

The React-specific concern is when these DOM writes happen relative to paint. useLayoutEffect fires synchronously after React commits DOM mutations and before the browser paints; useEffect fires after paint. For aria-sort and the announcement, the layout effect keeps the accessibility tree consistent with the frame the user sees.

WCAG criteria in scope: 1.3.1 (sort state conveyed programmatically), 2.1.1, 3.2.2, 4.1.2 Name, Role, Value, and 4.1.3.


When to use vs. when NOT to

Permalink to "When to use vs. when NOT to"

Use this pattern for a reading-mode <table> that users sort by activating a header button with Enter or Space — the overwhelming majority of enterprise sortable tables.

Do NOT:

  • Make the <th> itself the button. A <th tabindex="0"> with a click handler has no button role and fails 4.1.2. Nest a real <button type="button"> inside the header cell.
  • Reach for role="grid" just because the component is interactive. That commits you to arrow-key cell navigation and application mode; use a plain table unless cell navigation is the primary model.
  • Announce from useEffect. After-paint timing lets the visible order and the accessibility tree disagree for a frame. Use useLayoutEffect.
  • Rebuild the header row on every sort. If the header buttons are re-created, focus restoration targets a dead node.

Annotated code example

Permalink to "Annotated code example"
import { useState, useLayoutEffect, useRef } from 'react';

// Announcement region rendered once, high in the tree, so it is never
// unmounted by a re-render (WCAG 4.1.3 — status must persist to be observed).
function SortStatus({ message }) {
  return (
    // role="status" → implicit aria-live="polite" + aria-atomic
    <div role="status" aria-live="polite" aria-atomic="true" className="sr-only">
      {message}
    </div>
  );
}

function SortableTable({ columns, rows }) {
  // sort = { key, direction }; direction is 'ascending' | 'descending'
  const [sort, setSort] = useState({ key: null, direction: 'ascending' });
  const [status, setStatus] = useState('');
  const activatedKey = useRef(null);       // which header the user just pressed
  const headerRefs = useRef({});           // stable button node per column key

  // Derive the sorted rows for render (no mutation of props)
  const sortedRows = [...rows].sort((a, b) => {
    if (!sort.key) return 0;
    const dir = sort.direction === 'ascending' ? 1 : -1;
    return a[sort.key] < b[sort.key] ? -dir : a[sort.key] > b[sort.key] ? dir : 0;
  });

  // WCAG 4.1.2 + 4.1.3: write announcement AND restore focus synchronously,
  // after React commits the reordered rows but before paint.
  useLayoutEffect(() => {
    if (!sort.key) return;
    const col = columns.find(c => c.key === sort.key);
    // Announcement mirrors the aria-sort value now on the header
    setStatus(`Table sorted by ${col.label} ${sort.direction}`);
    // WCAG 3.2.2: focus stays on the header the user activated
    const btn = headerRefs.current[activatedKey.current];
    if (btn) btn.focus();
  }, [sort, columns]);

  function toggleSort(key) {
    activatedKey.current = key;
    setSort(prev =>
      prev.key === key
        ? { key, direction: prev.direction === 'ascending' ? 'descending' : 'ascending' }
        : { key, direction: 'ascending' }   // new column starts ascending
    );
  }

  return (
    <>
      <table>
        <caption>Team members — activate a column header to sort</caption>
        <thead>
          {/* Stable header row: nodes persist across sorts so focus survives */}
          <tr>
            {columns.map(col => {
              const isActive = sort.key === col.key;
              // WCAG 4.1.2: aria-sort reflects state; 'none' on inactive sortables
              const ariaSort = isActive ? sort.direction : 'none';
              return (
                <th key={col.key} scope="col" aria-sort={ariaSort}>
                  <button
                    type="button"
                    ref={el => { headerRefs.current[col.key] = el; }}
                    // Name encodes direction so JAWS (which ignores aria-sort) still speaks it
                    aria-label={
                      isActive
                        ? `${col.label}, sorted ${sort.direction}`
                        : `${col.label}, not sorted`
                    }
                    onClick={() => toggleSort(col.key)}
                  >
                    {col.label}
                    {/* Icon is decorative — hidden from AT (WCAG 1.1.1) */}
                    <span aria-hidden="true" className="sort-icon">
                      {isActive ? (sort.direction === 'ascending' ? '▲' : '▼') : '⇅'}
                    </span>
                  </button>
                </th>
              );
            })}
          </tr>
        </thead>
        <tbody>
          {sortedRows.map(row => (
            // Stable key by row id, NOT array index, so React moves rather than
            // rebuilds rows — keeps the DOM (and any focus within it) coherent.
            <tr key={row.id}>
              {columns.map(col => <td key={col.key}>{row[col.key]}</td>)}
            </tr>
          ))}
        </tbody>
      </table>
      <SortStatus message={status} />
    </>
  );
}

Two details carry the accessibility: the ref callback stores a stable button node per column so the layout effect’s .focus() lands on a live element, and the key={row.id} on each <tr> lets React move existing row nodes into sorted order rather than tearing them down and rebuilding them.


Keyboard & AT behaviour

Permalink to "Keyboard & AT behaviour"
Key / event Action Expected AT announcement Failure indicator
Enter / Space on header button Toggles sort direction “Status, sorted ascending, button” + “Table sorted by Status ascending” from the region Rows reorder but the region is silent
Tab Moves to the next header button Next button’s name and role Focus lost after the re-render
Re-sort completes Focus returns to the activated button Focus ring visible on the same header Focus falls to document.body
Navigate headers (NVDA) Reads aria-sort on each <th> “ascending” / “descending” / “not sorted” Inactive columns lack aria-sort="none"
Activate sort (JAWS) Reads the button aria-label “sorted descending” from the label Label omits direction; JAWS says only “button”

Integration context

Permalink to "Integration context"

This component is the React realisation of the sortable and filterable data grids reference, which covers the attribute timing, the live-region debounce, and the AT compatibility matrix in framework-neutral terms. For the precise rules on which elements may host aria-sort and how each screen reader treats it, see aria-sort attributes for accessible column filtering. If the same grid also offers row selection, the focus-preservation technique here is the same one used when revealing a batch toolbar in bulk selection and batch actions. The useLayoutEffect versus useEffect distinction generalises to any synchronous ARIA update; it recurs in focus management in single-page apps.


Gotchas

Permalink to "Gotchas"

1. useEffect announcing after paint

Permalink to "1. useEffect announcing after paint"

With useEffect, the row reorder paints first and the announcement/aria write happen a frame later. On fast toggles this can announce the previous sort or race the attribute update. useLayoutEffect closes the window by running before paint.

2. key={index} on rows

Permalink to "2. key={index} on rows"

Keying rows by array index makes React reuse the DOM node for a different data row after a sort, so the node the user focused now holds different content — and virtual-list or animation state smears across rows. Key by a stable row.id.

3. Re-created header buttons

Permalink to "3. Re-created header buttons"

If the header cells are conditionally re-mounted (for example, wrapped in a component that remounts on sort), the stored ref points at a detached node and .focus() silently no-ops. Keep the header row structurally stable; only aria-sort, the label, and the icon should change.


FAQ

Permalink to "FAQ"
Why use useLayoutEffect instead of useEffect for aria-sort?

useLayoutEffect runs synchronously after React mutates the DOM but before the browser paints, so the aria-sort attribute and the live region text are correct on the same frame the user sees the sorted rows. useEffect runs after paint, which can leave a window where the visible order and the accessibility tree disagree, and can cause a screen reader to announce the sort before or after the attribute settles. For a small announcement side effect tied to a render, useLayoutEffect keeps the tree consistent.

How do I keep focus on the header button after React re-renders the rows?

Give each header button a stable identity so React reuses the same DOM node across renders, and store which column was activated in state. In a layout effect after the sort, call focus on the button for that column. Because the header cells are not the nodes being reordered — only the body rows move — a stable key on the header row keeps the button node alive and the focus call lands on the right element.

Should a React sortable table use role=grid?

Not for a standard sort-on-header table. A native table with aria-sort on the <th> elements works in reading mode, which is what most users expect. role="grid" switches screen readers into application mode and commits you to full arrow-key cell navigation, which is more code and more surprising. Reach for role="grid" only when cell-by-cell keyboard navigation is the primary interaction, not merely because the table is interactive.


Permalink to "Related"

Back to Sortable & Filterable Data Grids