Build a file explorer / nested folder tree component
Recursive component that takes a tree node and renders folder/file. Toggle expansion state per node id (Set<string> in a parent or normalized store). Tri-state checkboxes propagate down on parent click and up on child change.
A file-explorer tree is the canonical "recursive data, recursive component" exercise. The interview signal is in the state shape (don't put expansion state on each node object — keep it lifted) and the tri-state checkbox logic (the tricky part).
Data model. A node is either a folder (has children) or a file. Don't store expanded or checked on the node itself — those are UI state, kept separately so the tree data stays clean and serializable.
type FileNode = { id: string; name: string; type: "file" };
type FolderNode = { id: string; name: string; type: "folder"; children: TreeNode[] };
type TreeNode = FileNode | FolderNode;Recursive component.
function TreeNodeView({ node, depth = 0 }: { node: TreeNode; depth?: number }) {
const { expanded, toggleExpanded } = useTreeState();
const isOpen = expanded.has(node.id);
return (
<li role="treeitem" aria-expanded={node.type === "folder" ? isOpen : undefined}>
<div style={{ paddingLeft: depth * 16 }} onClick={() => node.type === "folder" && toggleExpanded(node.id)}>
{node.type === "folder" ? (isOpen ? "📂" : "📁") : "📄"} {node.name}
</div>
{node.type === "folder" && isOpen && (
<ul role="group">
{node.children.map(c => <TreeNodeView key={c.id} node={c} depth={depth + 1} />)}
</ul>
)}
</li>
);
}Expansion state — Set<string>, lifted.
function useTreeState() {
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const toggleExpanded = useCallback((id: string) => {
setExpanded(prev => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
}, []);
return { expanded, toggleExpanded };
}Lifting to a parent (or a Zustand store) means: collapsed-by-default on every render, "expand all" / "collapse all" is one set update, and persistence is trivial.
Tri-state checkboxes (the hard part). Three checkbox states: checked, unchecked, indeterminate (some descendants are checked). Rules:
- Click a folder → check/uncheck all descendants.
- Click a leaf → propagate up: parent becomes checked if all children checked, indeterminate if some, unchecked if none.
Two approaches:
Approach A — store only leaf state. Keep checked: Set<leafId>. Compute folder state from descendants on render. Pros: no sync bugs. Cons: O(descendants) per folder render — fine for small trees.
function getFolderState(folder: FolderNode, checkedLeaves: Set<string>): "checked" | "unchecked" | "indeterminate" {
let total = 0, checked = 0;
function walk(n: TreeNode) {
if (n.type === "file") { total++; if (checkedLeaves.has(n.id)) checked++; }
else n.children.forEach(walk);
}
walk(folder);
if (checked === 0) return "unchecked";
if (checked === total) return "checked";
return "indeterminate";
}Approach B — store every node's state. Maintain Map<id, "checked" | "indeterminate" | "unchecked">. On click, walk descendants (set) and ancestors (recompute). Faster reads, more write logic.
For interview: pick A and call out the perf trade-off. For 10k+ nodes, switch to B with memoization.
The DOM checkbox indeterminate property. Not a regular attribute — set it via a ref:
const ref = useRef<HTMLInputElement>(null);
useEffect(() => { if (ref.current) ref.current.indeterminate = state === "indeterminate"; }, [state]);
return <input ref={ref} type="checkbox" checked={state === "checked"} onChange={onToggle} />;Performance for big trees.
- Memoize
TreeNodeViewby node id; sibling re-renders don't propagate. - Virtualize the rendered list — flatten the visible nodes (DFS, skip closed folders) and feed into
@tanstack/react-virtual. Tree-virtualization is non-trivial but mandatory at 5k+ nodes. - Persist expansion to localStorage so reloads don't collapse the user's open state.
Accessibility. Use role="tree" on the root, role="treeitem" on nodes, role="group" on children containers. aria-expanded on folders, aria-selected on current selection. Keyboard: ArrowDown/Up to move, ArrowRight to expand, ArrowLeft to collapse-or-go-to-parent, Enter to activate.
Common variants.
- Lazy-load children: folders fetch on first expand. Cache by id; show inline spinner.
- Drag-and-drop reorder/move: dnd-kit + tree-aware drop zones. Validate (don't drop folder into itself).
- Breadcrumbs: derive from the path of ancestor names of the active node.
Code
Follow-up questions
- •How do you handle the indeterminate checkbox state in the DOM?
- •How would you virtualize a tree?
- •How do you implement lazy-loading of folder children?
- •How do you handle keyboard navigation per WAI-ARIA tree pattern?
Common mistakes
- •Storing expansion/checked state on each node object — mutation pain, can't serialize cleanly.
- •Setting indeterminate as an attribute (it's a property only).
- •No memoization — every state change re-renders the entire tree.
- •Skipping ARIA roles — screen readers can't navigate the tree.
Performance considerations
- •Flatten + virtualize beyond a few hundred visible nodes.
- •Memoize TreeNodeView by id; subscribe per-node to state slices.
- •Compute folder state with memoized selectors (per parent id).
Edge cases
- •Empty folder → still expandable (shows '(empty)').
- •Drop folder into its own descendant → must reject.
- •Cyclic data → guard with a visited Set.
Real-world examples
- •VS Code file explorer, Postman collection sidebar, GitHub repo file browser.