# The React API — `av-grid/react` A React application writes `` and gets the real grid: same instance, same options, same performance. React owns one `
`; the grid owns everything inside it. **This page is the React side only**: the component, its props and update behavior, the ref, the filter bar, and hosting React components inside the grid's hooks. The vocabulary those props take — every option, column field, callback signature, filter shape, keyboard binding and CSS token — is defined once, framework-free, in [`api.md`](api.md); every one of them is a prop here, spelled identically. The wrapper is a **subpath export of this same package**, so its version can never drift from the core's. `react` and `react-dom` are **optional peer dependencies** — a vanilla consumer installs `av-grid` and never resolves a line of React. ```bash npm install av-grid react react-dom ``` ```tsx import { AVGrid } from "av-grid/react"; import "av-grid/av-grid.css"; // or let the grid inject it — see Styling const columns = [ { key: "name", name: "Name", width: 200 }, { key: "score", name: "Score", dataType: "number" }, ]; export function People({ people }) { return ( save(e.rowKey, e.columnKey, e.value)} style={{ height: 400 }} /> ); } ``` **`AVGrid` here is the component**, and it is the same export as `AVGridReact` — two names for one thing. The short one is what a React file wants; the long one stays unambiguous in a file that also imports the `AVGrid` *class* from `av-grid`. The instance type is exported as `AVGridInstance`, so a ref needs no second import: ```tsx import { AVGrid, type AVGridInstance } from "av-grid/react"; const gridRef = useRef>(null); ``` **Give the host a height.** The grid measures the element it is mounted into, exactly as [`AVGrid.create`](api.md) does. `style={{ height: 400 }}` works; so does a flex child with `style={{ flex: "1 1 auto", minHeight: 0 }}`. A host with no height renders a grid one pixel tall and no error. --- ## A thin adapter, not a controlled component Options flow **down** as props. State changes come **back** through the callbacks the core already has. A host that wants to *drive* the grid reaches the instance through the `ref` and calls the documented methods. The wrapper adds **no controlled-prop machinery of its own**: it does not re-push the grid's state on every render, and it keeps no second copy of it. That was tried once against this library and rejected (the *"controlled-props compatibility layer"* entry in [`tasks/plan-done-03.md`](../tasks/plan-done-03.md)). The mechanical consequence, worth stating because it is what people ask: **an imperative change is not reverted by the next render.** The wrapper diffs its own previous props, never the grid's live state. `gridRef.current.setFilters(…)` survives every re-render that does not itself pass a different `filters` prop. **None of which stops you writing `sort={sort}` or `focus={focus}`.** Every piece of grid state is an ordinary option, so every piece of it is an ordinary prop, and they round-trip safely — the next section. --- ## Props that hold grid state **Every piece of the grid's state is an option, so every piece of it is a prop** — and you may hold each in React state and echo its callback straight back down: | Prop | Reported by | Also reachable as | |---|---|---| | `sort` | `onSortChange` | `getSort()` / `setSort()`. **Arity follows `multiSort`**: one `SortColumn` by default, a `SortColumn[]` (`[]` when unsorted) with `multiSort: true` — hold whichever your grid uses in state and echo it back, both round-trip | | `filters` | `onFiltersChange` | `getFilters()` / `setFilters()` | | `selected` (row keys) | `onSelectionChange` | `getSelected()` / `setSelected()` | | `focus` (the focused cell and its range) | `onFocusChange` | `getFocus()` / `setFocus()` / `focusCell()` | | `searchString` | *(host-owned — the grid never changes it itself)* | `getSearchString()` / `setSearchString()` | ```tsx const [sort, setSort] = useState(); const [filters, setFilters] = useState(); ``` Click a header and the round trip is: the grid sorts → `onSortChange` → `setSort` → re-render → the wrapper sends `setOptions({ sort })` → **the core compares by value, finds no change, and returns.** One extra render, no second callback, no loop. That holds because each setter guards on value, not identity — `SortColumnModel` element by element (which is what makes the `multiSort` array echo terminate too), `FiltersModel` filter by filter, `SelectedModel` on set membership, `FocusModel` field by field. `src/react/AVGridReact.test.tsx` pins all four round trips and `src/model/FocusModel.test.ts` pins the focus guard directly, because an echo loop is exactly what would appear if one of those guards were ever dropped. The cost is one `setOptions` per commit in which the prop's *reference* changed. Keep the value in state, as above, rather than rebuilding it during render, and that is one call per real change. **Server-side filtering and sorting** is this same pattern plus two lane-3 booleans — `externalFilter` and `externalSort` (see [Host-owned filtering and sorting](api.md#host-owned-filtering-and-sorting) for what each one changes). The grid keeps its whole filter and sort UI but never re-filters or reorders the rows you pass; the callbacks drive your fetch instead of your state echo: ```tsx refetch({ filters })} // e.g. a React Query key onSortChange={(sort) => refetch({ sort })} onGetOptions={getOptions} // stable identity — the values are the server's /> ``` No new prop shape: both are plain booleans through the ordinary option diff, and a text filter's value arrives in `onFiltersChange` as `{ op: "contains" | "equals" | "startsWith", text: string }`, ready to translate into a query. `focus` is worth a word, because it is the newest of the four and the one people reach for when restoring a session: pass it **keys only** — `{ rowKey, columnKey, isDragging: false }` — and the grid resolves the indices against the rows as they are now, so a restored focus lands on the right row even after a sort. Add `selection` to restore a whole range. A focus passed on the first render is applied without reporting itself back, so mounting does not fire `onFocusChange`. --- ## The three update lanes Everything the wrapper does on a commit is one of these three. | Lane | Prop | What the wrapper does | |---|---|---| | 1 | `rows` | A new array reference → `grid.setRows(rows)`. Scroll position is kept and no cell element is torn down. Same reference → nothing at all. | | 2 | The callbacks | **Never diffed, never re-sent.** One stable proxy per callback is installed at `create()` and forwards to whatever the prop holds *now*, return value included. | | 3 | Everything else | `Object.is` against the previous commit. One `setOptions()` per commit, carrying only the keys that changed. A prop that **disappeared** is sent as `undefined`, which resets that option to its default. | So this component may be re-rendered as often as React likes, with inline arrow callbacks re-created every time, and the grid receives **zero** calls: ```tsx setEdits((n) => n + 1)} // lane 2 → nothing, and the newest closure fires onFocusChange={(f) => setFocus(f)} // lane 2 → nothing /> ``` ### Which props are callbacks The list is exported from the core as `CALLBACK_OPTION_KEYS`, and a compile-time check in `src/options.ts` fails the build if a function option is ever added without being classified — so the list cannot drift from the type. ```ts import { CALLBACK_OPTION_KEYS } from "av-grid"; ``` It holds every option the grid calls to *report* or to *intercept*: `onSelectionChange`, `onEdit`, `onInvalidEdit`, `onAddRows`, `onDeleteRows`, `onAddColumns`, `onDeleteColumns`, `onSortChange`, `onFiltersChange`, `filterLabel`, `onColumnResize`, `onColumnsReorder`, `onColumnsChange`, `onVisibleRowsChange`, `onFocusChange`, `onCellClick`, `onCellDoubleClick`, `onCellContextMenu`, `getContextMenuItems`, `onCellClass`, `rowClass`, `footerRowClass`, `columnGroupRender` and `columnGroupClass`. One nuance for the two group hooks: the band rewrites its cells' *content* when the columns change, not on every render — so a `columnGroupRender` whose output depends on outside state shows the new output on the next columns change, not immediately. Derive group content from `{ group, columns }` and this never matters. **Six function options are not on it**, because their mere *presence* changes what the grid does, so a proxy standing in for an absent one would change behaviour: | Option | What absence means | |---|---| | `getRowKey` | The grid infers a row key | | `newRow`, `newColumn` | The grid's own blank-row / blank-column defaults | | `onGetOptions` | A filter popover offers the column's distinct values | | `onGridContextMenu` | The grid draws its own context menu | | `onTreeToggle` | A static tree: the chevrons show open / closed and do nothing, `→` / `←` navigate | Every other option is a plain lane-3 prop — `headerHeight`, say, is diffed by value and sent through one `setOptions` when it changes, and removing the prop sends `undefined`, which puts the header back on `rowHeight`. Give those six a **stable identity** — a module-level function, or `useCallback` — or lane 3 will send a `setOptions` on every render. The same applies to `columns` **and to `footerRows`**: define them outside the component or memoize them, exactly as you would for any array prop — a footer array rebuilt during render sends a `setOptions` per commit. `treeColumn` is an object prop with the same rule; its `expanded` reader usually closes over host state, so a new object per toggle is expected and is exactly the repaint a toggle needs — `onTreeToggle` stays outside it so it can be stable on its own. Three callbacks are on the paint path: `onCellClass`, `rowClass` and `footerRowClass` are consulted per *cell*, and their presence alone makes the cell renderer build a context object it otherwise skips. The wrapper therefore proxies them **only when they are passed on the first render**. Pass them from the start if you pass them at all; one that appears later still works, but goes through lane 3. --- ## `className` and `style` are the host element's They land on the `
` this component renders — the element React is responsible for — which is what a React author expects and where a **height** has to go anyway. That means `className` here is *not* the core's `className` option (which puts a class on the grid root). The grid root inside the host still carries `.avg-grid`, so a descendant selector reaches it: ```css .my-grid .avg-grid { border-radius: 6px; } ``` --- ## The instance ref `ref.current` is the real `AVGrid` — `null` before mount and after unmount, the instance in between. Not a facade: every documented method on it is already the public API, and wrapping it in a second surface would only give the two something to drift apart over. ```tsx import { useRef } from "react"; import { AVGrid, type AVGridInstance } from "av-grid/react"; function Toolbar({ rows }: { rows: Row[] }) { const gridRef = useRef>(null); return ( <> ); } ``` --- ## The detached filter bar `filterBar: true` puts a bar of removable filter chips directly above the grid and needs nothing from this page. `` is for the other case: a bar mounted somewhere else — a toolbar, a side panel, a header above unrelated content. The one non-obvious line is that the grid instance does not exist on the first render, so the bar is handed `null` first. That is the normal first value, not an error case: keep the instance in state, set from the component's `ref`. ```tsx const [grid, setGrid] = useState | null>(null); ``` The ref callback puts the instance in state, the re-render hands it to the bar, and the bar mounts. A grid can have any number of bars; they all show the same filters and any of them can edit them. Props: `grid` (required, nullable), `barClassName` and `name` for the bar element itself, `clearButton={false}` to drop the remove-all ✕ (next section), and `className` / `style` for the host `
`. --- ## Your own filter chips `` takes `clearButton={false}` for a bar embedded in a toolbar that has its own clear control. And when the built-in chips themselves are not enough, render your own — `filters` is already in your state if you round-trip it, and everything a chip does is one call on the ref: ```tsx
{filters.map((f) => { const text = grid.describeFilter(f); // { name, values, title } — the built-in chip's strings return ( ); })}
``` `test-boards/ReactApp/` renders exactly this next to the built-in bar, so the two stay provably interchangeable. ## React components inside the grid's hooks The grid's customization hooks take DOM, because they run inside its own rendering. Two of them are safe places to mount a real React component, and `av-grid/react` ships an adapter for each: ```tsx import { reactEditor, reactFilterBody } from "av-grid/react"; // A component as a cell editor — ctx is the grid's EditorContext: // ctx.setValue(v) records, ctx.commit() / ctx.cancel() end the edit. { key: "city", editor: reactEditor((ctx) => ) } // A component as a filter popover body. Call setValue on every change (an onChange, // not a submit) — Apply reads whatever was recorded last, starting from ctx.value. const minScore: FilterDefinition = { name: "min-score", create: reactFilterBody((ctx, setValue) => ( )), label: (v) => `score ≥ ${v}`, match: (v, row) => row.score >= v, }; { key: "score", filter: minScore } ``` Why these two and not cell content: an editor and a filter body are **one instance at a time, opened by a user gesture, with an explicit `destroy`** — exactly what a React root needs. `Column.render` is the opposite: it runs on the paint path, sixty times a second while scrolling, against pooled elements that are recycled dirty and evicted without notice. A React root per cell is the cost this grid exists to avoid, so cell content stays an HTML string (or a cached DOM element) — still written inside your React file, just not JSX: ```tsx { key: "score", render: (c) => `${c.highlight(c.value)}` } ``` Everything `docs/api.md` promises the DOM versions of these hooks — the editor's positioning, Escape/Tab handling and teardown on every exit path; the filter's Apply/Clear, chip, persistence and cascade — applies to the adapted ones unchanged. The adapters also absorb the two timing traps: the component is mounted **synchronously** (`flushSync`), because the grid measures and focuses the element in the same tick the factory returns; and the unmount is **deferred a microtask**, because the grid calls `destroy` from inside the React event that committed, and React refuses to unmount a root while it is rendering. ## Styling and theming Nothing here is React-specific. The grid injects its stylesheet on first use, so the import of `av-grid/av-grid.css` above is optional — set `injectStyles={false}` if you would rather link it yourself, as a bundler-managed stylesheet or a CSP-friendly ``. Theming is the `--p-*` / `--avg-*` token contract described in [Theming](capabilities.md#theming): set the tokens on any ancestor — the app shell, a themed panel, `:root` — and every grid under it follows. There is no theme prop and there is not going to be one. --- ## StrictMode, and what the wrapper does on mount The grid is created in a `useLayoutEffect` (not `useEffect`: it measures its host, so it must exist before the browser paints) and destroyed in that effect's cleanup. React 19's development StrictMode double-invokes mount → cleanup → mount. That is safe here by construction rather than by a guard: `destroy()` is tested leak-free over 100 create/destroy cycles, and the second mount builds a fresh instance. One live grid, no warnings. --- ## Non-goals - **JSX cell content.** `render` and `editor` are vanilla-DOM factories and work unchanged from React — they are ordinary options. Rendering *React* into pooled cells through portals is a different thing: the pooling contract means a cell element is recycled and evicted under the portal's feet, and no consumer has needed it. It would be its own phase, with its own benchmark numbers. - **An editing session as a prop.** `startEdit()`, `commitEdit()`, `cancelEdit()` and `getEdit()` stay on the instance. An open editor is transient UI mid-keystroke, not state a host should be round-tripping through a render; the same goes for the scroll position, which has `scrollToRow()` and no option, because a prop that fights the user's own scrolling is a bug rather than a feature. - **Controlled-prop machinery.** See above. - **SSR / RSC.** The grid renders into a live DOM by design. The wrapper mounts in an effect, which never runs on the server, so a server render emits the empty host `
` and the grid appears on hydration. That is the support; there is nothing to configure. --- ## API summary ```ts import { AVGrid, AVGridReact, AVGridFilterBar, reactEditor, reactFilterBody } from "av-grid/react"; import type { AVGridProps, AVGridReactProps, AVGridInstance, AVGridFilterBarProps, } from "av-grid/react"; ``` | Export | Shape | |---|---| | `AVGrid` | The component: `AVGridOptions` minus `className`, plus `className` / `style` for the host, plus `ref?: Ref>` | | `AVGridReact` | The same export, under the name that cannot collide | | `AVGridProps`, `AVGridReactProps` | The props type, likewise under both names | | `AVGridInstance` | What a `ref` yields — a type alias over the core's `AVGrid` class | | `AVGridFilterBar` | `{ grid: AVGridInstance \| null, barClassName?, name?, clearButton?, className?, style? }` | | `reactEditor(render)` | `(ctx: EditorContext) => ReactElement` → a `Column.editor` — see [React components inside the grid's hooks](#react-components-inside-the-grids-hooks) | | `reactFilterBody(render)` | `(ctx: FilterBodyContext, setValue) => ReactElement` → a `FilterDefinition.create` | Everything else — the option and column types, `CALLBACK_OPTION_KEYS` — comes from `av-grid` itself. The React entry deliberately does not re-export the core's runtime. The full option, column, method and callback reference is [`docs/api.md`](api.md); it stays framework-free, and everything in it applies here unchanged.