Back to Machine Coding
Machine Coding
medium
mid

How would you build a file explorer with a nested folder tree?

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.

8 min read·~45 min to think through

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.

ts
type FileNode = { id: string; name: string; type: "file" };
type FolderNode = { id: string; name: string; type: "folder"; children: TreeNode[] };
type TreeNode = FileNode | FolderNode;

Recursive component.

tsx
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.

ts
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.

ts
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:

tsx
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 TreeNodeView by 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

tsx
function NodeCheckbox({ state, onToggle }: { state: "checked" | "unchecked" | "indeterminate"; onToggle: () => void }) {
  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} />;
}
Tri-state checkbox with indeterminate ref
ts
function flattenVisible(root: TreeNode, expanded: Set<string>, depth = 0, out: Array<{ node: TreeNode; depth: number }> = []) {
  out.push({ node: root, depth });
  if (root.type === "folder" && expanded.has(root.id)) {
    for (const c of root.children) flattenVisible(c, expanded, depth + 1, out);
  }
  return out;
}
Flatten visible nodes for virtualization

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.

Senior engineer discussion

Senior signal: lifting UI state out of the data model, tri-state propagation strategy, indeterminate-as-property gotcha, and tree virtualization.