File Explorer Tree View
Machine-coding brief for a file tree — expand/collapse, selection, keyboard tree pattern, lazy children, and icons.
- 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-expandedon 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)
- Tree node type — discriminant
type: 'file' | 'folder' - Flatten visible nodes for keyboard navigation
- Expansion state as
Set<string>of folder ids - MVP — render + expand + select; then keyboard + lazy
- 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-expandedonly on expandable itemsaria-selectedfor selection model- Loading:
aria-busyon 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
- Recursive component without stable keys → state loss
- Keyboard on collapsed children still “focused” in bad implementations
- Mutating tree in place instead of immutable updates
- Click on chevron also toggles selection twice — stopPropagation carefully
- Missing unique ids when mock data reuses names
Interview out-loud answer
I’d model a discriminated union for file/folder nodes and keep
expandedIdsas a set. Visible-node flattening drives arrow-key navigation per the tree APG pattern. Expand can trigger lazyloadChildren. 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.
Related on this site
- Nested Comments Thread
- Kanban Board
- Machine Coding Interview Framework
- Accessibility Interview Talking Points