Problem Understanding
The key insight: you cannot write this without recursion. You don't know how deep the tree is. A folder can contain folders that contain more folders. The only way to handle this is a component that renders itself for its children — a recursive component.
Why Local State — Not Lifted State
This is an important design question the interviewer might ask directly. Why put isOpen inside each FileNode instead of tracking all folder states at the root level?
// ❌ Lifted approach — complex, fragile
const [openFolders, setOpenFolders] = useState(new Set(['root', 'src']));
// Problem: requires a unique ID for every node in the tree.
// Managing a global Set of open folder IDs is more complex
// and adds no benefit when folder open/close doesn't need
// to be shared with any other component.
// ✅ Local state — simple, correct
function FileNode({ node, depth = 0 }) {
const [isOpen, setIsOpen] = useState(depth === 0); // root starts open
// Each node independently manages its own open state — this is fine
// because no other component needs to know which folders are open.
}
💡 Interview Tip: Say this when asked: "Folder open/close state is purely local — no other component needs to know which folders are expanded. Local state inside the recursive component is simpler and equally correct. I'd lift it only if, for example, a parent needed to show 'X folders expanded' or serialize the open state to a URL."
The Recursive Component
function FileNode({ node, depth = 0 }) {
const [isOpen, setIsOpen] = useState(depth === 0);
const isFolder = node.type === 'folder';
function getIcon() {
if (!isFolder) return '📄';
return isOpen ? '📂' : '📁';
}
return (
<div>
{/* This node's row */}
<div
style={{ paddingLeft: 8 + depth * 18 }}
className={`node-row ${isFolder ? 'node-folder' : 'node-file'}`}
onClick={() => isFolder && setIsOpen(o => !o)}
>
{isFolder && (
<span className={`arrow ${isOpen ? 'arrow-open' : ''}`}>▸</span>
)}
<span className="icon">{getIcon()}</span>
<span className="name">{node.name}</span>
</div>
{/* Children — only rendered when folder is open */}
{isFolder && isOpen && node.children?.map((child, i) => (
<FileNode
key={`${child.name}-${i}`}
node={child}
depth={depth + 1} // depth increments on each recursive call
/>
))}
</div>
);
}
The key Prop on Recursive Children
Using key={child.name + '-' + i} (not just child.name) handles the case where two siblings have the same name — unlikely in a real filesystem but possible in interview data. Adding the index guarantees uniqueness within siblings.
Conditional Render vs CSS Display
// ✅ Conditional render — children are removed from DOM when collapsed
{isFolder && isOpen && node.children?.map(...)}
// ❌ display:none — children are in the DOM but hidden
<div style={{ display: isOpen ? 'block' : 'none' }}>
{node.children?.map(...)}
</div>
For a file tree with thousands of nodes, conditional rendering is better — it keeps the DOM lean. Each collapsed folder's children don't exist in the DOM at all, not just hidden.
Common Pitfalls Summary
- Trying to write it without recursion: A fixed-depth approach (hardcoding 3 levels) breaks immediately if the tree is deeper.
- Lifting all folder states to root: Unnecessarily complex. Local state per node is simpler and correct when no external sharing is needed.
- Using display:none instead of conditional render: For large trees, this keeps all nodes in the DOM — defeats the purpose of collapsing.
- Missing depth prop increment: Without depth + 1 on each recursive call, all nodes render at the same indentation level.
- onClick on file nodes: Only folders should respond to click. Guard with isFolder check.