ESC

Type to search the knowledge base.

File Explorer Tree View

Machine-coding brief for a file tree — expand/collapse, selection, keyboard tree pattern, lazy children, and icons.

intermediate4 min read
  • machine-coding
  • interview
  • react
  • a11y
  • tree

Problem statement

Build a file explorer tree: nested folders/files, expand/collapse folders, select a node, and navigate with the APG tree keyboard pattern. Interviewers score recursive data modeling, controlled expansion state, and accessibility — not a full VFS.

Requirements

Must have

  • Render nested tree from data
  • Folders expand/collapse; files are leaves
  • Click selects node; optional open file callback
  • Keyboard per tree pattern: ↑/↓ visible nodes, ← collapse/parent, → expand/first child, Enter/Space activate
  • aria-expanded on folders; role="tree" / treeitem
  • Visual depth indent

Should have

  • Controlled selectedId / expandedIds
  • Lazy-load children when first expanded
  • Loading state on folder

Nice to have

  • Create / rename / delete
  • Drag-drop move
  • Multi-select
  • Virtualize large trees

Planning (5 minutes out loud)

  1. Tree node type — discriminant type: 'file' | 'folder'
  2. Flatten visible nodes for keyboard navigation
  3. Expansion state as Set<string> of folder ids
  4. MVP — render + expand + select; then keyboard + lazy
  5. Ids must be unique across the whole tree

Architecture

FileTree
├── TreeNode (recursive)
│   ├── Row (icon, name, chevron)
│   └── Children
└── utils/flattenVisible.ts

Data model

type FileNode = {
  id: string;
  name: string;
  type: "file";
};

type FolderNode = {
  id: string;
  name: string;
  type: "folder";
  children?: TreeNode[]; // omit or empty when lazy
  childrenLoaded?: boolean;
};

type TreeNode = FileNode | FolderNode;

type FileTreeProps = {
  root: TreeNode[];
  selectedId?: string | null;
  onSelect?: (node: TreeNode) => void;
  loadChildren?: (folderId: string) => Promise<TreeNode[]>;
};

Implementation sketch

Flatten for keyboard

function flattenVisible(
  nodes: TreeNode[],
  expanded: Set<string>
): TreeNode[] {
  const out: TreeNode[] = [];
  function walk(list: TreeNode[]) {
    for (const n of list) {
      out.push(n);
      if (n.type === "folder" && expanded.has(n.id) && n.children) {
        walk(n.children);
      }
    }
  }
  walk(nodes);
  return out;
}

Expansion + lazy load

function useTreeState(root: TreeNode[], loadChildren?: FileTreeProps["loadChildren"]) {
  const [expanded, setExpanded] = useState<Set<string>>(new Set());
  const [tree, setTree] = useState(root);
  const [loading, setLoading] = useState<Set<string>>(new Set());

  async function toggle(folderId: string) {
    const isOpen = expanded.has(folderId);
    if (isOpen) {
      const next = new Set(expanded);
      next.delete(folderId);
      setExpanded(next);
      return;
    }
    const next = new Set(expanded);
    next.add(folderId);
    setExpanded(next);

    if (loadChildren) {
      setLoading((s) => new Set(s).add(folderId));
      const children = await loadChildren(folderId);
      setTree((t) => patchChildren(t, folderId, children));
      setLoading((s) => {
        const n = new Set(s);
        n.delete(folderId);
        return n;
      });
    }
  }

  return { tree, expanded, loading, toggle };
}

function patchChildren(
  nodes: TreeNode[],
  id: string,
  children: TreeNode[]
): TreeNode[] {
  return nodes.map((n) => {
    if (n.id === id && n.type === "folder") {
      return { ...n, children, childrenLoaded: true };
    }
    if (n.type === "folder" && n.children) {
      return { ...n, children: patchChildren(n.children, id, children) };
    }
    return n;
  });
}

Tree item row

function TreeRow({
  node,
  depth,
  expanded,
  selected,
  onToggle,
  onSelect,
  tabIndex,
}: {
  node: TreeNode;
  depth: number;
  expanded: boolean;
  selected: boolean;
  onToggle: () => void;
  onSelect: () => void;
  tabIndex: number;
}) {
  const isFolder = node.type === "folder";
  return (
    <div
      role="treeitem"
      tabIndex={tabIndex}
      aria-selected={selected}
      aria-expanded={isFolder ? expanded : undefined}
      style={{ paddingLeft: depth * 16 }}
      onClick={onSelect}
      onKeyDown={(e) => {
        /* handled at tree level with flattened list */
      }}
    >
      {isFolder && (
        <button
          type="button"
          aria-label={expanded ? "Collapse" : "Expand"}
          onClick={(e) => {
            e.stopPropagation();
            onToggle();
          }}
        >
          {expanded ? "▼" : "▶"}
        </button>
      )}
      <span>{isFolder ? "📁" : "📄"}</span>
      <span>{node.name}</span>
    </div>
  );
}

Prefer one tab stop on the tree with roving tabIndex across visible treeitems; handle arrows on the focused item.

Keyboard map (APG)

Key Action
Down Next visible node
Up Previous visible node
Right Expand folder or move to first child
Left Collapse folder or move to parent
Home / End First / last visible
Enter Select / open file

Parent lookup: keep parentId map when flattening, or store path.

Accessibility essentials

  • Container: role="tree"; items: role="treeitem"
  • Nested groups: role="group" wrapping children
  • aria-expanded only on expandable items
  • aria-selected for selection model
  • Loading: aria-busy on folder row

Performance notes

  • Don’t re-render entire tree if only selection changes — memo rows
  • Large trees: flatten + virtualize visible list (still compute expand state)
  • Lazy children essential for “whole monorepo” scale

Footguns

  1. Recursive component without stable keys → state loss
  2. Keyboard on collapsed children still “focused” in bad implementations
  3. Mutating tree in place instead of immutable updates
  4. Click on chevron also toggles selection twice — stopPropagation carefully
  5. Missing unique ids when mock data reuses names

Interview out-loud answer

I’d model a discriminated union for file/folder nodes and keep expandedIds as a set. Visible-node flattening drives arrow-key navigation per the tree APG pattern. Expand can trigger lazy loadChildren. Selection is a single id. MVP is recursive render + expand + select; keyboard and lazy load earn senior signal. Mutations like rename are tree patches by id.

Further reading