React Arborist: The Definitive Guide
to Tree Views in React
From zero to a production-ready, drag-and-drop, virtualized React tree component —
with real code, real patterns, and zero fluff.
1. What Is react-arborist and Why Does It Exist?
If you’ve ever tried to build a React tree view component from scratch, you know the pain.
Recursive rendering looks elegant on a whiteboard and becomes a performance nightmare the moment
your data has more than a few hundred nodes. Add drag-and-drop, keyboard navigation, selection state,
and an accessible ARIA structure on top of that — and you’re no longer building a feature,
you’re building a small framework.
react-arborist is an open-source
React tree component built by the team at Brimdata. It was designed specifically
around the requirements of Zui, a desktop app that needs to display and navigate deeply
nested data structures efficiently. The result is a library that treats tree views not as a visual
flourish but as a first-class interactive data structure: virtualized, accessible, draggable,
and straightforward to integrate into your existing React state management setup.
The core philosophy here is refreshingly honest — the library does not try to handle your data mutations.
It manages rendering, interaction, and virtual scrolling. You own the data.
That separation of concerns is exactly what makes react-arborist composable
in real applications instead of becoming another black box that fights your architecture at every turn.
react-arborist relies on
react-virtual for windowed rendering, meaning it onlymounts visible rows in the DOM regardless of how many nodes your tree contains.
This makes it viable for file-system-scale hierarchies — thousands of nodes — without any
additional configuration from your side.
2. Installation and Initial Project Setup
Getting react-arborist into your project is one of the few things in this ecosystem
that isn’t an adventure. The library has a minimal peer-dependency surface: React 17 or 18,
and that’s essentially it. The underlying virtualization and DnD logic are bundled in,
so you won’t spend an afternoon chasing missing package warnings across your terminal.
Start with a standard Vite + React + TypeScript scaffold if you’re building fresh.
If you’re integrating into an existing project, skip straight to the install step.
The library ships its own TypeScript definitions, so there’s no @types/react-arborist
lookup needed.
bash
# Using npm
npm install react-arborist
# Using yarn
yarn add react-arborist
# Using pnpm
pnpm add react-arborist
# Scaffold a fresh project first (optional)
npm create vite@latest my-tree-app -- --template react-ts
cd my-tree-app
npm install
npm install react-arborist One important note on CSS: react-arborist ships zero default styles.
That’s deliberate — it gives you complete control over the visual layer without any
specificity battles or !important wars against a vendor stylesheet.
You bring your own design. The library brings the behavior.
This is the correct way to build a component library in 2025,
and it’s worth appreciating before you start hunting for a theme import that doesn’t exist.
tsx — verify the install
import { Tree } from "react-arborist";
// If TypeScript resolves this without errors, the setup is complete.
export default function App() {
return <div>react-arborist is ready</div>;
}3. Building Your First Tree in 20 Lines
The react-arborist getting started experience is notably smooth.
At its most basic, you need three things: a data array in the right shape,
a Tree component with a fixed height and width,
and a node renderer — a component that tells arborist how to draw each row.
That’s the entire mental model for the happy path.
The data shape deserves a moment of attention. Each node object must have an id
field (a unique string). Beyond that, structure is yours to define — arborist will look for
a children array to determine nesting, but you can remap that field name via props
if your existing data uses something like nodes or items.
This flexibility means you can drop arborist onto existing hierarchical data without
massaging your API response into a new shape first.
tsx — BasicTree.tsx
import { Tree, NodeApi, NodeRendererProps } from "react-arborist";
// 1. Define your data shape
interface FileNode {
id: string;
name: string;
children?: FileNode[];
}
// 2. Provide static data (swap for API data in real usage)
const data: FileNode[] = [
{
id: "1", name: "src",
children: [
{ id: "1-1", name: "App.tsx" },
{ id: "1-2", name: "main.tsx" },
{
id: "1-3", name: "components",
children: [
{ id: "1-3-1", name: "Button.tsx" },
{ id: "1-3-2", name: "Modal.tsx" },
]
}
]
},
{ id: "2", name: "package.json" },
{ id: "3", name: "tsconfig.json" }
];
// 3. Define the node renderer
function Node({ node, style, dragHandle }: NodeRendererProps<FileNode>) {
const isFolder = node.isInternal;
return (
<div
style={style}
ref={dragHandle}
onClick={() => node.toggle()}
>
{isFolder ? "📁" : "📄"} {node.data.name}
</div>
);
}
// 4. Render the Tree
export default function BasicTree() {
return (
<Tree
data={data}
height={400}
width={300}
rowHeight={36}
indent={24}
>
{Node}
</Tree>
);
} A few things to note in this minimal example. The style prop coming into your
node renderer is the virtualization offset — pass it directly to your wrapper element’s
style attribute or the row won’t be positioned correctly.
The dragHandle ref attaches the drag interaction to whichever element you choose;
typically your row wrapper, though you can limit it to a drag handle icon for a tighter UX.
And node.toggle() collapses or expands a folder node — it’s the sort of tiny API
surface that reveals how much thought went into keeping the renderer component minimal and focused.
Pass
height="100%" instead of a fixed pixel value if your tree containeruses a flex or grid layout. You’ll need to wrap it in a container with an explicit height
set via CSS — arborist needs a concrete pixel height to initialize the virtual scroller correctly.
4. Custom Node Rendering and Styling
The node renderer is where react-arborist becomes genuinely powerful.
Because you’re writing a regular React component, you have unconditional control over
what each tree row looks like and how it behaves. Icons, context menus, inline edit inputs,
status badges, selection checkboxes — all of it lives naturally inside your node component.
There’s no plugin system to learn, no slot API to understand, no custom render prop protocol
to navigate. You write React. That’s it.
The NodeApi object passed to your renderer carries everything you need to
make intelligent rendering decisions: node.isSelected, node.isDragging,
node.isEditing, node.isFocused, node.isOpen,
node.level, and node.data (your raw data object).
You won’t find yourself reaching for external state to know whether a node is currently
selected — it’s all sitting on the node reference.
tsx — StyledNode.tsx
import { NodeRendererProps } from "react-arborist";
function StyledNode({ node, style, dragHandle }: NodeRendererProps<FileNode>) {
const isFolder = node.isInternal;
const icon = isFolder
? node.isOpen ? "📂" : "📁"
: getFileIcon(node.data.name);
return (
<div
ref={dragHandle}
style={style}
className={[
"node-row",
node.isSelected && "selected",
node.isDragging && "dragging",
node.isFocused && "focused",
].filter(Boolean).join(" ")}
onClick={() => isFolder && node.toggle()}
>
{/* Indentation is already applied via `style`,
but you can add extra visual indicators */}
<span className="node-icon">{icon}</span>
{node.isEditing ? (
<input
defaultValue={node.data.name}
autoFocus
onBlur={e => node.submit(e.currentTarget.value)}
onKeyDown={e => {
if (e.key === "Enter") node.submit(e.currentTarget.value);
if (e.key === "Escape") node.reset();
}}
/>
) : (
<span className="node-name">{node.data.name}</span>
)}
</div>
);
}
// Simple file-type icon resolver
function getFileIcon(name: string): string {
if (name.endsWith(".tsx") || name.endsWith(".ts")) return "🔷";
if (name.endsWith(".json")) return "📋";
if (name.endsWith(".css")) return "🎨";
return "📄";
} The inline editing pattern shown above is worth calling out specifically.
When node.isEditing is true, you render an <input>
pre-filled with the current name. Calling node.submit(newValue) triggers
the onRename callback you pass to the Tree component — where
you actually update your data. Calling node.reset() cancels the edit without
any state change. It’s a genuinely clean pattern that keeps the renderer decoupled
from data mutation logic.
5. Drag and Drop: Making Trees Actually Interactive
React drag and drop tree implementations are notoriously fiddly.
Most solutions either bolt on a library like react-dnd or dnd-kit
and leave you to wire up the tree-specific semantics manually, or they provide DnD
that works inside the tree but breaks the moment you try to drag between two separate trees.
react-arborist sidesteps both failure modes by building drag and drop
into the core virtualization loop using the native HTML5 DnD API — which, for this use case,
turns out to be perfectly adequate.
Enabling drag and drop requires exactly one callback: onMove.
This fires whenever the user drops a set of dragged nodes onto a new position in the tree.
The callback receives an object with dragIds (the IDs of moved nodes),
parentId (the destination parent, or null for root-level drops),
and index (the position within the parent’s children array).
Your job in this callback is to update your data accordingly.
Arborist doesn’t mutate data — it only tells you what the user intended.
tsx — DraggableTree.tsx
import { useRef, useState } from "react";
import { Tree, TreeApi, MoveHandler } from "react-arborist";
export default function DraggableTree() {
const [data, setData] = useState(initialData);
/**
* onMove fires after a successful drag-and-drop.
* We need to:
* 1. Remove the dragged nodes from their current positions
* 2. Insert them at the target position
*/
const handleMove: MoveHandler<FileNode> = ({
dragIds,
parentId,
index,
}) => {
setData(prev => moveNode(prev, dragIds, parentId, index));
};
return (
<Tree
data={data}
onMove={handleMove}
height={500}
width={320}
>
{Node}
</Tree>
);
}
/**
* Pure helper: relocates nodes in an immutable tree structure.
* In production, consider a utility like immer for cleaner mutations.
*/
function moveNode(
data: FileNode[],
dragIds: string[],
parentId: string | null,
targetIndex: number
): FileNode[] {
// Step 1: collect the nodes being dragged
const dragged: FileNode[] = [];
const remaining = filterTree(data, dragIds, dragged);
// Step 2: insert at target
return insertNodes(remaining, dragged, parentId, targetIndex);
}
// filterTree and insertNodes implementations omitted for brevity.
// See the full gist in the article's GitHub repo. One thing the docs don’t always emphasize: if you don’t pass an onMove handler,
drag and drop is silently disabled. No error, no warning — the tree just won’t respond to drag
interactions. This is actually sensible behavior (opt-in DnD) but it can cause confusion during
initial setup. Also note that dropping a node onto a leaf node (a node without children)
makes it a sibling of that leaf, not a child. If you want to convert leaves to folders on drop,
you handle that in your onMove callback by checking the parentId.
The native HTML5 DnD API doesn’t fire drop events if the user’s cursor moves outside
the browser window. For Electron or desktop-style apps, test edge cases around
window-boundary drags carefully. Touch devices have limited support for native DnD —
consider a polyfill or custom pointer-event implementation if mobile is a target.
6. Building a React File Explorer with react-arborist
A React file explorer is the canonical use case for react-arborist,
and for good reason — Brimdata built the library while building exactly this type of UI.
The requirements map perfectly to what arborist provides out of the box:
a directory tree with expandable folders, draggable items, rename-on-double-click,
right-click context menus, multi-select, and keyboard navigation that would satisfy
even the most demanding power users.
The key insight when building a file explorer pattern is that you’re managing
three distinct layers of state: the tree structure (your data), the UI state
(which nodes are open, selected, or focused — managed by arborist internally),
and the mutation callbacks (create, rename, delete — managed by you).
Keep these layers cleanly separated and the implementation stays maintainable
as complexity grows.
tsx — FileExplorer.tsx (core callbacks)
import { useRef, useState } from "react";
import {
Tree, TreeApi,
CreateHandler,
RenameHandler,
DeleteHandler,
MoveHandler,
} from "react-arborist";
export default function FileExplorer() {
const [data, setData] = useState(initialFileTree);
const treeRef = useRef<TreeApi<FileNode>>(null);
// Called when a new node is created (e.g., toolbar "New File" button)
const handleCreate: CreateHandler<FileNode> = ({ parentId, index, type }) => {
const newNode: FileNode = {
id: crypto.randomUUID(),
name: type === "leaf" ? "untitled.ts" : "new-folder",
children: type === "internal" ? [] : undefined,
};
setData(prev => insertAt(prev, newNode, parentId, index));
return newNode; // Return the new node so arborist can start editing it
};
// Called when the user submits an inline rename
const handleRename: RenameHandler<FileNode> = ({ name, node }) => {
setData(prev => updateNodeName(prev, node.id, name));
};
// Called when the user deletes selected nodes (default: Delete key)
const handleDelete: DeleteHandler<FileNode> = ({ ids }) => {
setData(prev => removeNodes(prev, ids));
};
return (
<div className="file-explorer">
<Toolbar
onNewFile={() => treeRef.current?.createLeaf()}
onNewFolder={() => treeRef.current?.createInternal()}
/>
<Tree
ref={treeRef}
data={data}
onCreate={handleCreate}
onRename={handleRename}
onDelete={handleDelete}
onMove={handleMove}
height={600}
width={280}
disableMultiSelection={false}
>
{ExplorerNode}
</Tree>
</div>
);
} The ref access to the TreeApi is the part that unlocks toolbar-driven
actions. treeRef.current exposes imperative methods like createLeaf(),
createInternal(), delete(), selectAll(),
and scrollTo(id). This is the integration point for keyboard shortcuts,
toolbar buttons, and right-click context menus — you call these methods from your
UI event handlers rather than managing the corresponding state yourself.
For a production React directory tree pattern, pair the file explorer
with a detail panel that listens to onSelect. When the user clicks a file node,
you receive the selected node’s data and can render a preview, editor,
or property panel in a sibling component.
The tree becomes the navigation; your panel becomes the content.
It’s a clean separation that scales naturally to IDE-style layouts.
7. Advanced Usage: Search, Multi-Select, and Keyboard Control
Moving into react-arborist advanced usage territory means getting
comfortable with three things: the searchTerm prop, the TreeApi
imperative interface, and the selection model. These three together let you build
the kind of tree UI that users actually expect in a professional application —
not just something that expands and collapses.
Search in react-arborist works differently than you might expect.
Rather than filtering the visible list by hiding non-matching nodes,
arborist walks the entire tree and expands parent nodes automatically
to reveal any children that match the search term.
The default matching uses a case-insensitive substring check on node.data.name.
You can override this entirely with a custom searchMatch function
if you need fuzzy search, regex matching, or want to search across multiple node fields.
tsx — SearchableTree.tsx
import { useState, useRef } from "react";
import { Tree, TreeApi, NodeApi } from "react-arborist";
export default function SearchableTree() {
const [term, setTerm] = useState("");
const treeRef = useRef<TreeApi<FileNode>>(null);
// Custom match: fuzzy-ish match across name and file extension
const searchMatch = (
node: NodeApi<FileNode>,
term: string
) => node.data.name.toLowerCase().includes(term.toLowerCase());
return (
<div>
<input
type="search"
placeholder="Filter files..."
value={term}
onChange={e => setTerm(e.target.value)}
/>
<Tree
ref={treeRef}
data={data}
searchTerm={term}
searchMatch={searchMatch}
height={500}
width={320}
openByDefault={false}
onSelect={nodes => console.log("Selected:", nodes.map(n => n.id))}
>
{Node}
</Tree>
<div className="tree-controls">
<button onClick={() => treeRef.current?.openAll()}>Expand All</button>
<button onClick={() => treeRef.current?.closeAll()}>Collapse All</button>
<button onClick={() => treeRef.current?.selectAll()}>Select All</button>
</div>
</div>
);
} Keyboard navigation is one of those features that’s easy to overlook during development
and impossible to ignore in production. By default, react-arborist implements a complete
keyboard control scheme: arrow keys for navigation, Enter to toggle folders or start editing,
Escape to cancel edits, Space to select nodes, and Ctrl/Cmd+A to select all.
You can intercept and override specific keys with the onKeyDown event on your
outer container, but for most applications the built-in behavior will be sufficient
and save you from reinventing a well-understood UX pattern.
8. Performance with Large Hierarchical Datasets
React hierarchical data performance is a topic that sounds academic
until your tree has 10,000 nodes and your client’s laptop fan starts sounding like a
jet engine. Traditional recursive tree rendering creates a DOM node for every single
tree item — expanded or not, visible or not. At scale, this becomes untenable:
mounting, styling, and event-listening on thousands of elements simultaneously
will crater your rendering performance regardless of how clever your memoization strategy is.
react-arborist addresses this by maintaining a flat, ordered list of currently visible
nodes internally, then passing it to react-virtual for windowed rendering.
Only the rows currently in the viewport (plus a small overscan buffer) are mounted in the DOM.
This means a fully expanded tree of 50,000 nodes renders roughly as fast as one with 50 —
because the DOM footprint stays constant at approximately viewport height divided by row height.
That’s the right abstraction. It’s why arborist can handle file-system-scale data without
any special configuration on your end.
Beyond the built-in virtualization, there are a few additional performance patterns worth
adopting in complex deployments. First, memoize your node renderer component with
React.memo — arborist passes consistent node references for unchanged nodes,
making memoization effective. Second, if your data comes from an API,
consider lazy-loading children: render stub nodes for folders,
then fetch their children only when the user expands them.
Arborist supports this pattern naturally since you control when and how
the children array is populated. Third, avoid computing derived data
(like file sizes, modification dates) inside the node renderer —
pre-compute it during data normalization so each render is a pure display operation.
Wrap your node renderer in
React.memo().Pre-normalize API data before passing to
data prop.Use
openByDefault={false} for large trees to minimize initial visible nodes.Use
scrollTo(id) from TreeApi rather than manually scrolling the container.9. react-arborist vs Other React Tree View Libraries
The React tree view library landscape is more crowded than you’d expect.
Before committing to arborist, it’s worth understanding what the alternatives offer
and where arborist specifically wins. The honest answer is:
it depends on your requirements, but for anything involving drag-and-drop,
large datasets, or production-quality keyboard accessibility,
arborist’s combination of features is currently unmatched in the React ecosystem.
✦ react-arborist
- Virtualized rendering (fast at scale)
- Built-in DnD, no extra library
- Full keyboard navigation
- Inline editing built-in
- TypeScript-first
- Zero default styles
- TreeApi imperative interface
react-d3-tree
- SVG-based, visually richer
- Horizontal / radial layouts
- No virtualization
- Limited interactivity
- Best for org charts / graphs
rc-tree
- Feature-rich, mature
- Checkbox support
- Async data loading
- Heavier bundle
- DnD requires extra config
- Opinionated default styles
react-treebeard
- Animated expand/collapse
- Simple API
- No virtualization
- No DnD built-in
- Limited maintenance activity
The nuance is that react-arborist optimizes for the file explorer interaction model:
navigating, selecting, reorganizing, and editing nodes in a list-style vertical tree.
If your use case is visualizing relationships in a horizontal org chart or rendering
a force-directed graph, a D3-based library is a better fit.
If you need checkboxes with tri-state logic on a static tree with a few hundred nodes,
rc-tree has robust built-in support.
But if you’re building something interactive, large, and needs to feel like a native
desktop application inside a browser — arborist is the answer.
One real competitive advantage that deserves explicit mention: arborist is maintained
by a team that actually uses it in their production application. That’s not a minor detail.
It means bugs that matter to real users get fixed, edge cases get tested against actual
workflows, and the API evolves in response to genuine usage patterns rather than
hypothetical feature requests on GitHub issues.
For a library you’re going to stake a production feature on, that maintenance signal matters.
Frequently Asked Questions
The most common questions about react-arborist, answered concisely.
Q1
How do I install and set up react-arborist in a React project?
Run npm install react-arborist (or the yarn/pnpm equivalent).
Import the Tree component from "react-arborist",
provide your data as an array of objects with at least an id field
and an optional children array for nesting.
Set a fixed height and width on the Tree component,
and pass a custom node renderer component as its child.
The library ships TypeScript definitions and requires no additional style imports —
you own the CSS completely. That’s the full setup. First render takes under 5 minutes.
Q2
How does drag and drop work in react-arborist?
react-arborist uses the native HTML5 Drag and Drop API internally — no external DnD library required.
Drag and drop is activated by passing an onMove callback to the Tree component.
When the user drops a node, the callback fires with dragIds (moved node IDs),
parentId (destination parent, or null for root level),
and index (position in the parent’s children).
Your callback is responsible for updating the underlying data — arborist does not mutate state directly.
If no onMove callback is provided, drag interactions are silently disabled.
Q3
Can react-arborist handle large datasets efficiently?
Yes — this is one of react-arborist’s primary design goals.
It uses virtualized rendering via react-virtual, meaning only the visible rows
are actually mounted in the DOM at any given time. A tree with 50,000 nodes renders
with the same DOM footprint as one with 50, because the rendered row count stays proportional
to viewport height, not total node count. For even better performance with large trees,
wrap your node renderer in React.memo(), use openByDefault={false},
and consider lazy-loading children on folder expand rather than loading the full tree upfront.
Ready to build your tree?
react-arborist covers everything from a minimal 20-line prototype to a full
production file explorer with drag-and-drop, search, and keyboard accessibility.
The learning curve is shallow; the ceiling is high.









