Building a High-Performance React Virtualized Tree View
A practical guide to designing and implementing a fast, accessible React virtualized tree view with lazy loading, keyboard support, and drag‑and‑drop.
Image used for representation purposes only.
Overview
A tree view is the backbone of many developer tools and enterprise apps: file explorers, API hierarchies, permission graphs, and organizational charts. When the tree holds tens of thousands of nodes, a naïve render will grind the UI to a halt. Virtualization—only rendering what’s visible—turns that non-starter into a smooth experience.
This article walks through the architecture of a React virtualized tree view: modeling data, flattening expanded nodes, rendering with a windowing library, handling variable heights, keyboard navigation and ARIA, selection, drag‑and‑drop, async loading, search, and performance tactics.
Why virtualize a tree?
- Large data: 10k–1M nodes cannot be mounted at once.
- Frequent changes: expand/collapse, filters, and dynamic loading cause re-renders.
- Complex rows: icons, inline actions, and badges increase render cost.
Virtualization lets you:
- Keep DOM nodes near the viewport (often <150 elements total).
- Maintain consistent 60fps scroll and expand/collapse.
- Reduce memory pressure and layout thrash.
Design goals and constraints
- Performance: O(visible) rendering and updates; stable keys; memoized rows.
- Accessibility: proper roles, focus handling, and keyboard support.
- Correctness: expansion state, multi-select, drag targets, and async states.
- Extensibility: support custom row content, badges, and actions.
Choosing a windowing engine
Three widely used options:
- react-window: small, modern, fixed/variable size lists.
- TanStack Virtual: headless, flexible, SSR-friendly, strong variable-size story.
- react-virtualized: feature-rich but older; still useful for CellMeasurer.
For most trees, start with react-window or TanStack Virtual.
Data modeling the tree
Represent both the hierarchical structure and a flattened “visible list” computed from expansion state.
Minimal node shape (TypeScript):
export type TreeNode = {
id: string;
name: string;
isLeaf?: boolean;
parentId?: string | null;
children?: string[]; // child ids (optional when lazy)
hasChildren?: boolean; // true if children are known to exist (for lazy)
loading?: boolean; // async expansion in progress
meta?: Record<string, unknown>;// arbitrary data (icons, counts, etc.)
};
export type TreeState = {
expanded: Set<string>;
selected: Set<string>;
focusedId?: string;
};
A separate index keeps nodes by id for O(1) lookups:
type NodeIndex = Map<string, TreeNode>;
Flattening the visible nodes
Virtualizers operate on a flat array. Generate it by traversing depth-first from the root, including only nodes whose ancestors are expanded.
export type FlatRow = {
id: string;
depth: number; // for indentation
isLeaf: boolean;
isExpanded: boolean;
hasChildren: boolean;
};
export function buildVisibleRows(
rootIds: string[],
index: NodeIndex,
expanded: Set<string>
): FlatRow[] {
const out: FlatRow[] = [];
const stack: Array<{ id: string; depth: number }>=[];
// Push roots in reverse so first root appears first when popped
for (let i = rootIds.length - 1; i >= 0; i--) stack.push({ id: rootIds[i], depth: 1 });
while (stack.length) {
const { id, depth } = stack.pop()!;
const n = index.get(id);
if (!n) continue;
const hasChildren = Boolean(n.children?.length || n.hasChildren);
const isExpanded = hasChildren && expanded.has(id);
out.push({
id,
depth,
isLeaf: !hasChildren,
isExpanded,
hasChildren,
});
if (isExpanded && n.children?.length) {
for (let i = n.children.length - 1; i >= 0; i--) {
stack.push({ id: n.children[i], depth: depth + 1 });
}
}
}
return out;
}
Key properties:
- Stable order derived from data order.
- Depth provides simple padding for indentation.
- Expanded affects which descendants are emitted.
Rendering with react-window (VariableSizeList)
For fixed-height rows, FixedSizeList is simplest. Trees often have variable-height items (wrapping text, badges), so we’ll use VariableSizeList with a measuring hook.
import { VariableSizeList as List, ListOnScrollProps } from 'react-window';
import React from 'react';
type TreeProps = {
rows: FlatRow[];
rowHeight: (index: number) => number; // cached measure
width: number | string;
height: number;
onToggle: (id: string) => void;
renderLabel: (id: string) => React.ReactNode;
};
export function VirtualTree({ rows, rowHeight, width, height, onToggle, renderLabel }: TreeProps) {
const listRef = React.useRef<List>(null);
const Row = React.useCallback(({ index, style }) => {
const r = rows[index];
const indent = (r.depth - 1) * 16; // px per level
return (
<div style={{ ...style, display: 'flex', alignItems: 'center', paddingLeft: indent }}
role="treeitem"
aria-level={r.depth}
aria-expanded={r.isLeaf ? undefined : r.isExpanded}
aria-selected={false}
data-id={r.id}
>
{!r.isLeaf && (
<button
aria-label={r.isExpanded ? 'Collapse' : 'Expand'}
onClick={() => onToggle(r.id)}
style={{ width: 20 }}
>
{r.isExpanded ? '▾' : '▸'}
</button>
)}
<span style={{ flex: 1, whiteSpace: 'nowrap', textOverflow: 'ellipsis', overflow: 'hidden' }}>
{renderLabel(r.id)}
</span>
</div>
);
}, [rows, onToggle, renderLabel]);
return (
<div role="tree" aria-label="Explorer" style={{ width, height }}>
<List
ref={listRef}
itemCount={rows.length}
itemSize={rowHeight}
width={width}
height={height}
overscanCount={8}
>
{Row as any}
</List>
</div>
);
}
Notes:
- overscanCount buffers offscreen rows for smooth scrolling.
- role=“tree”/“treeitem” and aria-level/aria-expanded set the ARIA structure.
- Button toggles expansion without focusing the row; customize as needed.
Measuring variable row heights
Two common strategies:
- Predictable heuristics: derive height from content length and line-height; cache by id.
- True measurement: render an offscreen (or positioned) element and observe with ResizeObserver, updating the cached height and invalidating the list.
Example measuring hook:
export function useRowHeights(rows: FlatRow[], defaultH = 28) {
const sizeMap = React.useRef<Map<string, number>>(new Map());
const getSize = React.useCallback((index: number) => {
const id = rows[index]?.id;
return (id && sizeMap.current.get(id)) || defaultH;
}, [rows, defaultH]);
const setSize = React.useCallback((id: string, size: number) => {
const prev = sizeMap.current.get(id);
if (prev !== size) sizeMap.current.set(id, size);
}, []);
return { getSize, setSize };
}
Attach to each row with a ResizeObserver component that calls setSize(id, height) and then listRef.current?.resetAfterIndex(index). Be careful to debounce to avoid thrashing.
Keeping scroll position stable on expand/collapse
Expanding above the viewport can push content down and move the anchor. Tactics:
- Compute delta of list total size before/after and call scrollBy(delta).
- Use an anchor id (the topmost visible row), find its new index post-change, and scrollToItem(anchorIndex, ‘start’).
- Batch state updates in a transition to avoid layout jank.
Keyboard navigation and ARIA
Follow the WAI-ARIA tree pattern:
- Container: role=“tree” with label.
- Rows: role=“treeitem”.
- Groups of children: role=“group” if you render nested containers (virtualized lists often skip explicit groups; aria-level is sufficient).
- Attributes: aria-level, aria-expanded (only if not leaf), aria-selected, aria-disabled (optional).
Keyboard behaviors:
- Up/Down: move focus to previous/next visible item.
- Left: collapse if expanded; otherwise move focus to parent.
- Right: expand if collapsible; otherwise move to first child.
- Home/End: first/last visible item.
- Typeahead: alphanumeric search within siblings or entire tree.
- Space/Enter: toggle selection/activation.
Implementation tips:
- Use roving tabIndex so only the focused row is tabbable.
- Maintain focusedId in state; scroll it into view with listRef.scrollToItem.
- Announce changes with aria-live if loading children.
Selection models
Support single and multi-select:
- Single: selected = Set([id]).
- Toggle with Ctrl/Cmd; range with Shift: compute range on the flattened array indices.
Store selection as ids; derive visual state from rows. For range selection, remember the last “anchor” index.
Drag-and-drop
- Libraries: @dnd-kit or react-beautiful-dnd (archived) for high-level, or native HTML5 for light control.
- Provide drop targets: on node (make child), before/after node (reorder among siblings). Show a drop indicator line or shaded “into” state.
- Autoscroll when pointer nears list edges; ensure virtualization overscan is sufficient to reveal targets in time.
- Validate canDrop: prevent moving a node into its own subtree.
- Announce via aria-dropeffect and visually with clear affordances.
Async loading (lazy children)
- On expand of a node with hasChildren && no children yet, set loading=true and fetch children.
- Optimistically show a spinner row beneath the parent; once loaded, insert child ids, update index, and rebuild rows.
- Deduplicate requests; cache results by node id.
- Preserve focus/scroll by anchoring to the expanded node.
Search and filter
- Text filter: highlight matches and auto-expand ancestor chain for matching descendants.
- Structure filter: show only folders, only changed files, etc.
- Implementation: compute a filtered set of ids; auto-ensure ancestors are expanded; rebuild rows.
- Keep original expansion state to restore when clearing filters.
Performance checklist
- Use React.memo for Row; pass stable props (ids, numbers, booleans).
- Item data: prefer primitives; avoid passing large objects each render.
- Stable keys: row keys should be node ids, not indices.
- Batch state: wrap expand/search updates in startTransition.
- Avoid list re-creations: keep list component mounted while rows change.
- Overscan: 4–12 rows typically balances smoothness vs. work.
- CSS containment: contain: content for row containers can reduce layout cost.
- Avoid expensive shadows and blurs inside rows.
Testing and quality
- Unit test buildVisibleRows: expansion behavior, order, depth, and edge cases (missing nodes, cycles guarded by input guarantees).
- a11y tests: axe-core or jest-axe to catch role/attribute issues.
- Keyboard E2E: playwright to verify focus movement, Home/End, and typeahead.
- Performance: measure with the Performance panel; validate commit times under 5–8ms for typical actions.
When to reach for a library
If you prefer a prebuilt component:
- Headless a11y primitives: react-aria’s Tree (when available) or design-system tree components.
- UI kits: MUI TreeView (not virtualized by default; combine with windowing if needed).
- Virtualization engines: TanStack Virtual or react-window + a11y wrappers.
Prebuilt trees may trade flexibility for speed or vice versa. For massive datasets and custom UX, a bespoke virtualized tree is often best.
Putting it together: minimal architecture
- Data: NodeIndex + rootIds.
- State: expanded Set, selected Set, focusedId.
- Derivation: rows = buildVisibleRows(rootIds, index, expanded).
- Virtualization: VariableSizeList with cached heights and overscan.
- Interaction: roving tabIndex, keyboard handlers, onToggle, range select.
- Enhancements: async loaders, search with ancestor expansion, DnD.
Conclusion
A performant React tree view balances careful data modeling, a flattened representation, and a lean virtualized renderer. Get the fundamentals—stable ids, memoized rows, robust keyboard/a11y—right first. Then layer on variable heights, async loading, search, and drag‑and‑drop. With that foundation, your tree will scale from hundreds to millions of nodes while staying accessible and responsive.
Related Posts
Building Performant Infinite Scroll in React: Patterns, Pitfalls, and Production Tips
Build performant React infinite scroll using IntersectionObserver, React Query, and virtualization, with production tips and code examples.
Building an Accessible, Animated React Accordion (Headless, Tested, and Themeable)
Build an accessible, animated React accordion: ARIA semantics, keyboard support, headless API, smooth height transitions, tests, and performance tips.
Designing a Rock‑Solid React Dark–Light Theme System
A practical, scalable React dark–light theme system using CSS variables, design tokens, and SSR-safe bootstrapping—fast, accessible, and maintainable.