Skip to main content
react

What a Data Grid Teaches You That a Combobox Can't

Building @reactzero/lattice forced problems a combobox never surfaces: two-dimensional focus, grid semantics, announcement timing, and a plugin pipeline where every feature wants to own the row array.


9 min read

A combobox is a solved problem with a spec. I've written before about what building one teaches you — the ARIA 1.2 pattern tells you almost every keystroke, and the discipline is in following it. A data grid gives you a spec too. Then real usage breaks it in an afternoon.

I built @reactzero/lattice after years of designing the surfaces a grid exists to serve — dispatcher dashboards, financial documents, notification feeds. Those are the tools where someone busy and under pressure scans hundreds of rows against the clock. The combobox taught me correctness. The grid taught me things correctness alone doesn't cover.


Focus Stops Being a List Problem

A combobox's keyboard model is one-dimensional: ArrowDown moves down the list, ArrowUp moves up, aria-activedescendant tracks the active option. There is exactly one axis, and the spec covers it.

A grid is a coordinate system. The ARIA grid pattern requires arrow-key navigation in both axes, and — this is the part that reorganizes your architecture — the entire grid is one tab stop. Tab enters the grid; Tab leaves it. Everything inside is arrow keys. If every cell were tabbable, a keyboard user would press Tab four hundred times to get past your table to the button below it.

That single requirement means focus can't be an afterthought bolted onto cells. Something has to know the current coordinate, clamp it at the edges, restore it when focus re-enters, and keep it valid when sorting reorders every row under the user's cursor. In lattice that responsibility is its own hook (useGridKeyboard), because it is genuinely its own subsystem. In a combobox, focus management is a feature. In a grid, it's infrastructure.

role="table" or role="grid" Is a Product Decision

Here's a tension that doesn't exist in combobox-land: ARIA has two vocabularies for tabular data, and they tell screen readers different stories.

role="table" says this is static content — read it like a document. role="grid" says this is an interactive widget — expect the one-tab-stop model, expect arrow-key navigation, expect cells to be operable. Choose grid for a read-only price list and you've promised keyboard behavior you don't deliver. Choose table for an editable dispatch board and screen-reader users get no hint that the cells do anything.

Lattice chooses grid, and then pays for the promise:

// what the container actually renders
{
  role: 'grid',
  'aria-rowcount': rowCount ?? (filteredRows.length || data.length),
  'aria-colcount': columns.length || undefined,
}

aria-rowcount matters more than it looks. When pagination means only 25 of 12,000 rows are in the DOM, the count tells assistive tech the real size of the dataset — "row 3 of 12,000," not "row 3 of 25." The DOM is a window; the semantics describe the data. Nothing about a combobox ever forces you to think about the difference, because a combobox never renders a 12,000-item listbox on purpose.

Sorting has its own contract: the header cell carries aria-sort="ascending" | "descending" | "none", and it lives on the columnheader, not on the button inside it. Details like that are why lattice grew a LiveRegion component before it grew a Footer.

Screen Readers Don't Announce What You Think

In a combobox, announcements are mostly free — aria-activedescendant changes, the screen reader reads the new option. In a grid, the interesting state changes are global: "sorted by amount, descending." "42 rows match." "Page 3 of 18." No focus moved, so nothing announces unless you announce it.

The naive fix is an aria-live region you push strings into. Then two real-world problems show up. First, interactions come in bursts — a user clicks sort twice, types three characters into a filter — and a live region that fires on every event turns into noise. Second, identical consecutive messages don't re-announce: if the user sorts, scrolls, and sorts back, the message "sorted by name, ascending" is the same string as last time, and screen readers stay silent.

Lattice's announcer handles both, and the second fix is my favorite kind of unglamorous:

announce: (message, priority = 'polite') => {
  clearTimeout(timer.current)
  timer.current = setTimeout(
    () => setPolite(m => (m === message ? `${message} ` : message)),
    debounceMs, // 150ms — collapses bursts into one announcement
  )
}

The debounce collapses bursts. And when the new message equals the old one, it appends a trailing space — visually identical, but a different string, so the live region fires again. It's a hack in the best sense: small, honest, and in service of someone you'll never see using your table at 2 a.m. with a screen reader.

There are two channels (polite and assertive) because "42 rows match" can wait for the screen reader to finish its sentence, and "row deleted" can't.

Every Feature Wants to Own the Row Array

This is the architecture lesson a combobox never teaches, because a combobox has one transformation: filter the items by the input.

A grid has at least three — sort, filter, paginate — and each one, written naively, wants to be the owner of the rows. My earliest design pulled all of them into the core engine. It worked, and it made "tiny" a lie: everyone shipped the sorting comparators and pagination math whether their table needed them or not.

The version that survived inverts it. The core engine runs an explicit pipeline; features are plugins that opt in:

import { Grid } from '@reactzero/lattice/react'
import { sortPlugin } from '@reactzero/lattice/sort'
import { filterPlugin } from '@reactzero/lattice/filter'

<Grid data={rows} plugins={[sortPlugin(), filterPlugin()]}>

Order is behavior — sort → filter → paginate, explicitly, so "page 2" means page 2 of the filtered, sorted result and not whatever the reducer happened to compute first. Each plugin ships as its own subpath export, so a read-only grid tree-shakes down to roughly 6 kB and never pays for the comparators. The pipeline being explicit is also what makes it explainable — I can tell you exactly what happens to your rows and in what order, which is more than I could say about my first draft.

The same thinking shows up one layer down: the shape of the API response decides how painful all of this is. A grid is where frontend architecture and backend contracts stop being separate conversations.

The Row Template Is the Part People Notice

Every mainstream React table makes you define columns as configuration — arrays of { accessorKey, header, cell }. It works, and it reads like a database schema wearing a JSX costume. Tables want to be declared the way they render:

<Grid data={rows} plugins={[sortPlugin()]}>
  <Grid.Header>
    <Grid.HeaderCell columnKey="name">Name</Grid.HeaderCell>
    <Grid.HeaderCell columnKey="role">Role</Grid.HeaderCell>
  </Grid.Header>
  <Grid.Body>
    <Grid.Row>
      <Grid.Cell columnKey="name" />
      <Grid.Cell columnKey="role" />
    </Grid.Row>
  </Grid.Body>
</Grid>

One <Grid.Row> template; the engine loops it over your data. Cells read from row context, so they survive being wrapped in divs and conditionals. And because the grid instance is typed against your data, columnKey autocompletes to real field names and a typo is a compile error, not an empty column in production.

I wrote the case for boring, readable library code before; the row template is the same value applied to the consumer's code instead of mine.

Tables Are the Graduation Exercise

When I reverse-engineered GitHub's Primer, I ended with their tables on purpose: buttons teach you variants, tables teach you inheritance, density, and where a system starts to fork. Building a grid from scratch is the same exam at a higher level. It forces two-dimensional focus, honest semantics for data that isn't in the DOM, announcement timing, and an architecture where features negotiate over a shared row array instead of owning it.

A combobox proves you can follow a spec. A grid proves you can make decisions where the spec runs out — which is most of the job.

The full story of lattice and its three sibling libraries — including the dead ends — is in the ReactZero case study. The package is on npm, zero dependencies, MIT.