File Explorer
by Pratik Rai
Read the full write-upBuild a file explorer that starts empty and lets someone build a tree by hand.
One button to begin with. From there, every folder can be expanded, collapsed, and added to — and folders you create can themselves be added to, arbitrarily deep.
Requirements
The initial state is a single Create src button. Clicking it creates a root folder named
src.Folders expand and collapse. Files do not.
Every folder has a + Add control that opens a small form asking for a type (file or folder) and a name.
The name field starts at a sensible default:
NewFile.txtfor a file,NewFolderfor a folder.Confirming adds the item inside that folder. Folders created this way have their own + Add, so nesting can go arbitrarily deep.
Clicking a file selects it — visual feedback is enough.
Notes
The styling is already written;
.name-selectedhandles the selection highlight.makeNodeis given to you, so ids are handled.
Hints
File Explorer (reference solution)
One way to do it, not the only way. Yours passes if it renders.
Solution files
1import { useState } from 'react';
2
3let nextId = 1;
4const makeNode = (name, type) => ({
5 id: 'n' + nextId++,
6 name,
7 type,
8 children: type === 'folder' ? [] : undefined,
9});
10
11export default function App() {
12 const [root, setRoot] = useState(null);
13 const [selectedId, setSelectedId] = useState(null);
14
15 // Insert a child by rebuilding the path down to the target folder.
16 //
17 // Mutating node.children and calling setRoot(root) would hand React the same
18 // object it already has, and nothing would re-render. Every ancestor of the
19 // changed node has to be a new object.
20 const addChild = (parentId, child) => {
21 const insert = (node) => {
22 if (node.id === parentId) {
23 return { ...node, children: [...node.children, child] };
24 }
25 if (!node.children) return node;
26 return { ...node, children: node.children.map(insert) };
27 };
28 setRoot((current) => insert(current));
29 };
30
31 if (!root) {
32 return (
33 <div className="explorer">
34 <h1>File explorer</h1>
35 <button className="create-root" onClick={() => setRoot(makeNode('src', 'folder'))}>
36 Create src
37 </button>
38 </div>
39 );
40 }
41
42 return (
43 <div className="explorer">
44 <h1>File explorer</h1>
45 <Node
46 node={root}
47 isRoot
48 onAdd={addChild}
49 selectedId={selectedId}
50 onSelect={setSelectedId}
51 />
52 </div>
53 );
54}
55
56function Node({ node, isRoot, onAdd, selectedId, onSelect }) {
57 // Open/closed and "is the dialog showing" both belong to this node, so they
58 // live here rather than in a map at the top. Each Node instance keeps its own.
59 const [open, setOpen] = useState(true);
60 const [adding, setAdding] = useState(false);
61 const [type, setType] = useState('file');
62 const [name, setName] = useState('NewFile.txt');
63
64 const isFolder = node.type === 'folder';
65
66 const startAdding = () => {
67 setAdding(true);
68 setType('file');
69 setName('NewFile.txt');
70 setOpen(true);
71 };
72
73 const confirm = () => {
74 const trimmed = name.trim();
75 if (trimmed) onAdd(node.id, makeNode(trimmed, type));
76 setAdding(false);
77 };
78
79 return (
80 <div className={'node' + (isRoot ? ' node-root' : '')}>
81 <div className="row">
82 {isFolder ? (
83 <button
84 className="twisty"
85 onClick={() => setOpen((v) => !v)}
86 aria-expanded={open}
87 aria-label={(open ? 'Collapse ' : 'Expand ') + node.name}
88 >
89 {open ? '▾' : '▸'}
90 </button>
91 ) : (
92 <span className="twisty" />
93 )}
94
95 <span className="icon">{isFolder ? '📁' : '📄'}</span>
96
97 <button
98 className={'name' + (selectedId === node.id ? ' name-selected' : '')}
99 onClick={() => onSelect(node.id)}
100 >
101 {node.name}
102 </button>
103
104 {isFolder && (
105 <button className="add" onClick={startAdding}>
106 + Add
107 </button>
108 )}
109 </div>
110
111 {adding && (
112 <div className="dialog">
113 <label>
114 <input
115 type="radio"
116 name={'type-' + node.id}
117 checked={type === 'file'}
118 onChange={() => {
119 setType('file');
120 setName('NewFile.txt');
121 }}
122 />
123 File
124 </label>
125 <label>
126 <input
127 type="radio"
128 name={'type-' + node.id}
129 checked={type === 'folder'}
130 onChange={() => {
131 setType('folder');
132 setName('NewFolder');
133 }}
134 />
135 Folder
136 </label>
137 <input
138 type="text"
139 value={name}
140 onChange={(e) => setName(e.target.value)}
141 onKeyDown={(e) => {
142 if (e.key === 'Enter') confirm();
143 if (e.key === 'Escape') setAdding(false);
144 }}
145 aria-label="Name"
146 />
147 <button onClick={confirm}>Create</button>
148 <button onClick={() => setAdding(false)}>Cancel</button>
149 </div>
150 )}
151
152 {isFolder &&
153 open &&
154 node.children.map((child) => (
155 <Node
156 key={child.id}
157 node={child}
158 onAdd={onAdd}
159 selectedId={selectedId}
160 onSelect={onSelect}
161 />
162 ))}
163 </div>
164 );
165}How it works
Two ideas carry this problem, and interviewers are watching for both.
The first is recursion in the component tree. One Node that renders itself for each child handles any depth, and the "folders you create must also be addable" requirement becomes free rather than a second code path.
The second is immutable insertion. The tempting version finds the target folder, pushes onto its children, and calls setRoot(root). Nothing happens — the root is the same object, so React skips the render, and the data is now silently out of step with the screen. insert instead returns a new object for the target and for every ancestor along the path, leaving untouched branches shared. That is exactly the structural sharing Immer and Redux reducers do by hand.
Keeping expanded state inside each Node is a deliberate choice worth defending out loud: it is local UI state that nothing else needs, so it does not belong in a map at the top. The moment a requirement appears like "collapse all", it has to move up — and being able to say when you would move it is the answer the interviewer wants.