Virtualized finance grids and COA trees at scale
Row identity, windowed fetches, and tree navigation for hospitality FP&A grids when the chart of accounts is thousands of nodes deep.
Dense finance UIs are a design problem and a memory problem. Once the chart of accounts (COA) spans thousands of nodes and each leaf carries dozens of period columns, mounting every cell in React stops being a brave choice and becomes a bug. Virtualization is the baseline—but virtualization without stable row identity and COA-aware fetching still feels broken to analysts on month-end.
This post goes deeper than layout patterns: how we window data, keep expand/collapse coherent, and sync a COA tree with a virtual grid without duplicating truth.
Virtualization is not "use a big table library"
Virtualizers need:
- Fixed or measurable row height (or a cache of measured heights for variable rows).
- Stable keys across sort, filter, and expand operations.
- Scroll restoration when async children load.
Finance grids break all three when subtotals inject synthetic rows. A group row at depth 2 is not the same component as a leaf row, but both need predictable height or the scroll thumb lies.
I default to uniform height for group rows and slightly taller leaves where line-wrap is allowed—never per-row auto-measure in hot paths. Accountants scroll with muscle memory; jittery scrollbars read as untrustworthy numbers.
Row identity that survives expand/collapse
Use deterministic IDs:
| Row kind | ID pattern |
|---|---|
| Group | group:{materializedPath} |
| Leaf | leaf:{propertyId}:{accountCode}:{periodKey} |
When a user expands 4xxx Operating, fetch leaves for that path only. Collapsing should not destroy cached leaf data if they re-expand within the session—stale-while-revalidate keeps the grid snappy.
type GridRow =
| { kind: "group"; id: string; path: string[]; depth: number; expanded: boolean }
| { kind: "leaf"; id: string; account: string; values: Record<string, number | null> };
If your API returns flat rows without paths, materialize the tree once on the client and index by path. Do not re-derive paths on every render from string labels—renamed accounts will fork state.
COA tree as the filter source of truth
The tree panel and the grid must share selection state:
- Selecting a node sets
activePathin URL query or global store. - The grid requests
GET /balances?path=4xxx&period=2026-P05. - Search in the tree filters nodes, not grid rows directly—then grid follows the selected hit.
Search should match code and alias ("Banquet COGS" → 5210). Hospitality COAs mix statistical accounts with monetary lines; disable variance math on stat rows in column defs so users do not export nonsense.
Windowed fetch vs load-all
| Dataset size | Strategy |
|---|---|
| < 2k visible leaves | Single fetch + client virtualizer |
| 2k–20k leaves | Paginate by expanded path + period |
| Portfolio rollups | Server aggregates; grid shows groups only until drill |
Never send the entire COA flattened JSON to the browser because it is easier for the backend team. The frontend will pay in memory; users will pay in crashes on older corporate laptops.
Column metadata drives the renderer
Generate columns from metadata: period keys, variance pairs, commentary flags. Cell renderers receive { row, column, entitlements } so role-based column hiding does not fork the grid per persona.
Formatting rules live in one module: negatives, null vs zero, materiality coloring. Export uses the same formatters—display rounding must match CSV rounding.
Tree + grid focus and keyboard
Power users live on keyboard. Tree typeahead, arrow expand, Enter to drill, Escape to pop scope. Grid Tab order within visible window only—virtualizers often break roving tabindex unless you wire it explicitly.
When the tree selection changes, scroll the grid to the first matching leaf if visible; if not loaded, show a skeleton row count matching expected children so height does not collapse to zero.
Testing what virtualizers hide
- Expand deepest path, scroll to bottom, collapse root—scroll offset should not NaN.
- Swap period in URL while expanded—rows should remount with new keys, not reuse leaf cells with old numbers.
- Role switch mid-session—columns disappear without shifting row keys.
When not to virtualize
Small property-level P&Ls with < 200 rows can use a simple table for faster iteration. Virtualization tax is real: measure before you add. The hospitality portfolio view is the forcing function; single-site ops reports might not need it.
Takeaway
COA depth is the constraint. Virtualization is how you stay honest under that constraint. Pair it with path-based fetching and shared tree-grid state, and dense FP&A UIs stop being a demo and start surviving close week.