Blog

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 kindID pattern
Groupgroup:{materializedPath}
Leafleaf:{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 activePath in 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 sizeStrategy
< 2k visible leavesSingle fetch + client virtualizer
2k–20k leavesPaginate by expanded path + period
Portfolio rollupsServer 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.