Tree View / File Explorer in React: Expand, Select, Drag
Build a fully interactive React tree view with expand/collapse, multi-select, and drag-and-drop reordering — no library required. Real code, real patterns.
Why Tree Views Are Harder Than They Look
A tree view sounds simple. It's just nested lists, right? Then you sit down to build one and realize you need recursive rendering, keyboard navigation, expand/collapse state spread across an arbitrarily deep graph, and — if you want drag-and-drop — a whole rethink of where your state lives. Most tutorials show you a pretty-looking <ul> and leave the hard parts as an exercise.
This guide doesn't do that. You'll build a file-explorer-style tree in React 18 that handles expand/collapse per node, multi-select with shift-click, and HTML5 drag-and-drop reordering — with real TypeScript types and no extra dependencies beyond React itself. Worth noting: the patterns here also apply to comment threads, org charts, and category pickers. Trees show up everywhere once you start looking.
Honestly, the hardest part isn't the recursion. It's keeping the expanded/selected state outside the recursive component so it doesn't reset on every re-render. We'll fix that from the start.
Quick aside: if you want a polished baseline to style on top of, Empire UI ships component primitives including cards, modals, and interactive elements that pair cleanly with custom tree structures — you don't have to build everything from scratch.
Data Model and TypeScript Types
Before writing a single JSX line, nail the data shape. Each node needs an id (unique, stable), a label, an optional children array (leaf nodes omit it), and whatever domain data you want to attach — type, path, icon, etc.
// types/tree.ts
export interface TreeNode {
id: string;
label: string;
type: 'folder' | 'file';
children?: TreeNode[];
// optional metadata
path?: string;
extension?: string;
}
export interface TreeState {
expanded: Set<string>; // node ids that are open
selected: Set<string>; // node ids that are selected
dragging: string | null; // id of node being dragged
}Using Set<string> for expanded and selected is a small thing that makes a big difference. Membership checks are O(1), spreading a Set into a new Set for immutable updates is trivial, and you don't have to filter arrays every render. In React 18 with concurrent mode, that matters.
One more thing — keep your tree data normalized if it's coming from an API. A flat Record<string, TreeNode> with children stored as id[] is far easier to update than a deeply nested object. The recursive UI can reconstruct the visual tree from that flat map at render time.
Recursive Rendering and Expand/Collapse
The core insight: the recursive component should be *dumb*. It reads state, calls handlers — it doesn't own anything. Put all state in a parent TreeView component and pass it down via props or context.
// components/TreeView/TreeNode.tsx
import { TreeNode as TNode, TreeState } from '../../types/tree';
import { ChevronRight, Folder, File } from 'lucide-react';
interface TreeNodeProps {
node: TNode;
depth: number;
state: TreeState;
onToggle: (id: string) => void;
onSelect: (id: string, multi: boolean) => void;
onDragStart: (id: string) => void;
onDrop: (targetId: string) => void;
}
export function TreeNodeItem({
node,
depth,
state,
onToggle,
onSelect,
onDragStart,
onDrop,
}: TreeNodeProps) {
const isExpanded = state.expanded.has(node.id);
const isSelected = state.selected.has(node.id);
const hasChildren = (node.children?.length ?? 0) > 0;
return (
<li className="list-none">
<div
className={[
'flex items-center gap-1 px-2 py-1 rounded-md cursor-pointer select-none',
isSelected ? 'bg-violet-500/20 text-violet-300' : 'hover:bg-white/5',
].join(' ')}
style={{ paddingLeft: `${depth * 16 + 8}px` }}
onClick={(e) => onSelect(node.id, e.metaKey || e.shiftKey)}
draggable
onDragStart={() => onDragStart(node.id)}
onDragOver={(e) => e.preventDefault()}
onDrop={() => onDrop(node.id)}
>
{hasChildren ? (
<button
className="p-0.5 rounded hover:bg-white/10"
onClick={(e) => { e.stopPropagation(); onToggle(node.id); }}
>
<ChevronRight
size={14}
className={`transition-transform ${isExpanded ? 'rotate-90' : ''}`}
/>
</button>
) : (
<span className="w-5" />
)}
{node.type === 'folder' ? <Folder size={14} /> : <File size={14} />}
<span className="text-sm">{node.label}</span>
</div>
{isExpanded && hasChildren && (
<ul>
{node.children!.map((child) => (
<TreeNodeItem
key={child.id}
node={child}
depth={depth + 1}
state={state}
onToggle={onToggle}
onSelect={onSelect}
onDragStart={onDragStart}
onDrop={onDrop}
/>
))}
</ul>
)}
</li>
);
}The paddingLeft calculation — depth * 16 + 8 — is the 16px indent per level that VS Code uses (literally copied from VS Code's 2020 source audit). You could use a Tailwind class map instead, but the dynamic calculation is cleaner for arbitrary depth.
Notice how onToggle and onSelect bubble up to the parent. The e.stopPropagation() on the chevron click is load-bearing — without it, clicking the expand arrow also fires the select handler on the row, which is wrong behavior.
State Management: Expand, Select, Shift-Select
Here's the parent TreeView component that owns all the state and wires up the handlers. This is where multi-select with shift gets slightly interesting.
// components/TreeView/index.tsx
import { useCallback, useReducer } from 'react';
import { TreeNode, TreeState } from '../../types/tree';
import { TreeNodeItem } from './TreeNode';
type Action =
| { type: 'TOGGLE'; id: string }
| { type: 'SELECT'; id: string; multi: boolean }
| { type: 'SET_DRAGGING'; id: string | null }
| { type: 'MOVE_NODE'; fromId: string; toId: string };
function reducer(state: TreeState, action: Action): TreeState {
switch (action.type) {
case 'TOGGLE': {
const next = new Set(state.expanded);
next.has(action.id) ? next.delete(action.id) : next.add(action.id);
return { ...state, expanded: next };
}
case 'SELECT': {
if (action.multi) {
const next = new Set(state.selected);
next.has(action.id) ? next.delete(action.id) : next.add(action.id);
return { ...state, selected: next };
}
return { ...state, selected: new Set([action.id]) };
}
case 'SET_DRAGGING':
return { ...state, dragging: action.id };
default:
return state;
}
}
const INITIAL_STATE: TreeState = {
expanded: new Set(),
selected: new Set(),
dragging: null,
};
interface TreeViewProps {
nodes: TreeNode[];
onMove?: (fromId: string, toId: string) => void;
}
export function TreeView({ nodes, onMove }: TreeViewProps) {
const [state, dispatch] = useReducer(reducer, INITIAL_STATE);
const onToggle = useCallback(
(id: string) => dispatch({ type: 'TOGGLE', id }),
[]
);
const onSelect = useCallback(
(id: string, multi: boolean) => dispatch({ type: 'SELECT', id, multi }),
[]
);
const onDragStart = useCallback(
(id: string) => dispatch({ type: 'SET_DRAGGING', id }),
[]
);
const onDrop = useCallback(
(targetId: string) => {
if (state.dragging && state.dragging !== targetId) {
onMove?.(state.dragging, targetId);
}
dispatch({ type: 'SET_DRAGGING', id: null });
},
[state.dragging, onMove]
);
return (
<ul className="py-2">
{nodes.map((node) => (
<TreeNodeItem
key={node.id}
node={node}
depth={0}
state={state}
onToggle={onToggle}
onSelect={onSelect}
onDragStart={onDragStart}
onDrop={onDrop}
/>
))}
</ul>
);
}The reducer pattern is intentional. You could do this with useState and five separate setter calls, but useReducer makes the state transitions explicit and testable — you can unit-test the reducer function in complete isolation from React. For a component this stateful, that matters in 2026.
That said, this SELECT handler only does toggle-select and single-select. Real shift-range-select — selecting all nodes between the last clicked and the current one — requires a flat ordered list of visible node IDs, which means you need a memoized flattened view of your tree. It's a few more lines, but the pattern is: compute flatVisibleIds, find lastSelectedIndex and currentIndex, then select the slice between them.
In practice, most apps don't actually need shift-range-select in a tree. Cmd/Ctrl multi-select gets you 90% of the way there, and it's what VS Code uses by default anyway.
Drag and Drop: Reordering and Reparenting
The HTML5 Drag and Drop API is underrated for this use case. No extra library, works in every browser that matters in 2026, and the visual ghost is free. The catch is that dragover needs e.preventDefault() to allow drops, and you have to track the drag source in state (or a useRef) because the DragEvent doesn't tell you which React node started the drag.
What we have above handles moving a node onto another node — good for reparenting (moving a file into a folder). If you want *between-node* reordering (insert above/below), you need to render drop zones between items. The trick is a thin <div> with height: 2px that expands to height: 16px on dragover so users have a visible target.
// A between-item drop zone
function DropZone({ onDrop }: { onDrop: () => void }) {
const [active, setActive] = useState(false);
return (
<div
className={`transition-all mx-2 rounded ${
active ? 'h-4 bg-violet-500/30 border border-violet-500' : 'h-0.5'
}`}
onDragOver={(e) => { e.preventDefault(); setActive(true); }}
onDragLeave={() => setActive(false)}
onDrop={() => { setActive(false); onDrop(); }}
/>
);
}One subtle issue: if your tree re-renders during a drag (because React state updates), the browser can cancel the drag. Keep mutations that happen *during* dragging (like hover highlighting) in local component state or a useRef rather than in the shared tree reducer. Only commit the final move on drop.
Look, if you need cross-browser touch support for drag on mobile, the HTML5 API won't cut it — you'd need @dnd-kit/core or react-dnd. But for desktop file explorers and admin tools, native HTML5 DnD is the right call. Fewer bytes, less abstraction, same result.
Keyboard Navigation and Accessibility
A tree that only works with a mouse isn't done. ARIA has a treeitem role with a clear spec: arrow keys navigate, Enter selects, Space toggles. This is one of those accessibility patterns where following the spec exactly actually produces the best UX for everyone, not just screen reader users.
// Add to the row <div> in TreeNodeItem
onKeyDown={(e) => {
if (e.key === 'ArrowRight' && hasChildren && !isExpanded) {
onToggle(node.id);
}
if (e.key === 'ArrowLeft' && isExpanded) {
onToggle(node.id);
}
if (e.key === 'Enter' || e.key === ' ') {
onSelect(node.id, false);
e.preventDefault();
}
}}
tabIndex={0}
role="treeitem"
aria-expanded={hasChildren ? isExpanded : undefined}
aria-selected={isSelected}The aria-expanded attribute should be undefined (not false) on leaf nodes — screen readers announce "expanded" or "collapsed" only for nodes that actually have children. Setting it to false on a file node confuses VoiceOver.
Worth noting: add role="tree" to the root <ul> and role="group" to nested <ul> elements. That gives assistive tech the full semantic context it needs to announce "3 of 7 items, level 2" style position information.
For visual styling, pair your tree with a dark-panel container and some of the color tokens from Empire UI — the component library's violet/indigo accent scale maps perfectly to selected-item highlighting, and you'll spend zero time bikeshedding on rgba values.
Performance: Virtualization for Large Trees
For trees under 500 nodes, none of this matters — render the whole thing. For trees with thousands of nodes (a real repository tree, a large org chart), you need virtualization. @tanstack/react-virtual is the right tool: it measures the scroll container and only renders the visible rows.
The catch with virtualizing a tree is that you need a flat list of *currently visible* nodes, not the nested structure. Compute it once whenever expanded changes with a depth-first traversal that skips children of collapsed nodes:
function flattenVisible(
nodes: TreeNode[],
expanded: Set<string>,
depth = 0
): Array<{ node: TreeNode; depth: number }> {
const result: Array<{ node: TreeNode; depth: number }> = [];
for (const node of nodes) {
result.push({ node, depth });
if (expanded.has(node.id) && node.children) {
result.push(...flattenVisible(node.children, expanded, depth + 1));
}
}
return result;
}
// Usage — memoize this
const visibleNodes = useMemo(
() => flattenVisible(nodes, state.expanded),
[nodes, state.expanded]
);Pass visibleNodes into @tanstack/react-virtual's useVirtualizer hook with a fixed estimateSize of around 28px per row, and you can handle 10,000+ nodes without breaking a sweat. The row component renders from the flat list using each item's depth for indentation — same formula as before, just no recursion at render time.
That said, unless you're building a code editor or a file manager for actual file systems, virtualization is probably overkill. Most UI trees top out at a few hundred nodes. Profile before you add the complexity — react-performance-guide on the Empire UI blog covers when to actually reach for virtualization versus other optimizations.
FAQ
Not for most use cases. The patterns above — useReducer for state, recursive rendering, HTML5 DnD — handle expand/collapse, multi-select, and drag-and-drop without any extra packages. Reach for @dnd-kit only if you need touch drag support or complex animations.
Keep hover/drag-over highlighting in local component state or a ref, not the shared tree reducer. Only dispatch to the reducer on dragend or drop. This stops React from re-rendering the entire tree every time the cursor moves over a new node.
The root <ul> gets role="tree", nested <ul> elements get role="group", and each row gets role="treeitem" with aria-expanded (only on nodes with children) and aria-selected. Follow the ARIA Authoring Practices Guide tree pattern exactly — it's well-documented and VoiceOver/NVDA both handle it well.
Flatten your visible nodes into a plain array (skipping children of collapsed nodes) and pass that to @tanstack/react-virtual. Each row renders using its stored depth value for indentation, so you get virtual scrolling with full indentation — no recursion at render time.