A collapsible, nested folder/file tree with expand-all, rename, and delete interactions.
Illustration coming soon
HOW TO USE THIS CHALLENGE
1. Read the briefClarify decisions before coding.
2. Build from memoryUse the 50-minute target.
3. Study the guideCompare architecture, tests, and trade-offs.
REQUIREMENTS
Render an arbitrarily nested tree of folders and files from a recursive data structure.
Folders expand/collapse independently and remember their state.
Support create, rename, and delete for both files and folders.
Deleting a folder removes all of its descendants.
Indentation should clearly communicate depth.
EDGE CASES
Renaming to an empty string or a name that collides with a sibling should be rejected with feedback.
Deleting the currently-selected node should clear or move the selection sensibly.
Very deep nesting shouldn't break horizontal layout on narrow screens.
Expand-all/collapse-all must handle cycles gracefully if the data is ever malformed (defensive, not required to support cycles).
ACCESSIBILITY
Model the tree with role="tree", role="treeitem", and aria-expanded on folder nodes.
Arrow keys should navigate the tree (up/down between visible rows, right/left to expand/collapse or move to parent).
Rename inputs should trap Enter (confirm) and Escape (cancel) without submitting a surrounding form.
SUGGESTED APPROACH
Model each node with a stable id, name, type, and children (for folders); keep expanded state in a Set of ids rather than mutating the tree shape.
Render recursively: a Node component that renders itself, then maps children through the same component.
Keep rename/create as local input state on the node being edited, committed on blur or Enter.
Compute deletion by filtering the children array of the parent, found via a lookup map from id to parent id built once from the tree.
EVALUATION RUBRIC
Tree renders and recurses correctly for arbitrary depth without key warnings.
Expand/collapse state persists correctly per node across unrelated re-renders.
Rename/delete update the underlying data structure immutably.
Keyboard tree navigation matches the ARIA treeview pattern reasonably closely.
01Understand the product before coding
Learning goals
Render an arbitrarily nested tree of folders and files from a recursive data structure.
Folders expand/collapse independently and remember their state.
Support create, rename, and delete for both files and folders.
Deleting a folder removes all of its descendants.
Decisions to state aloud
Renaming to an empty string or a name that collides with a sibling should be rejected with feedback.
Deleting the currently-selected node should clear or move the selection sensibly.
Very deep nesting shouldn't break horizontal layout on narrow screens.
Expand-all/collapse-all must handle cycles gracefully if the data is ever malformed (defensive, not required to support cycles).
02State model and invariants
Normalize nodes by id. Folders own ordered child ids; expanded, focused, selected, and editing ids are separate UI concerns. This makes rename direct and deletion explicit without recursively cloning the entire tree.
03Recursive TreeNode renders hierarchy and groups.
04flattenVisible produces keyboard order from expansion state.
05InlineRename owns only an uncommitted draft.
04Reference implementation walkthrough
Step 1
Flatten visible nodes
Walk roots depth-first and descend only into expanded folders. Track visited ids so malformed cycles terminate. Up and Down use this visual sequence, not raw storage order.
Right expands or enters a folder, Left collapses or returns to the parent, and Home/End use visible boundaries. Only focused treeitem has tabIndex zero.
Create changes one node record and one child list. Rename validates against sibling names. Delete collects descendants, removes their records, and calculates the next surviving focus target before commit.
Step 4
Treat rename as an editing mode
Enter validates and commits, Escape cancels, and blur behavior is explicitly chosen. Stop tree navigation handlers while the text input owns keyboard editing.
SOLComplete solution, explained simply
Build it yourself first. This is one correct implementation, not the only one — read it top to bottom, then compare the shape of your version.
We are building a file tree you can navigate with the keyboard, expand and collapse, rename in place, and delete. The one big idea: store every file and folder in a flat object keyed by id, and compute the on-screen list from which folders are open. Arrow keys move through that computed list, never through the raw storage.
FileExplorer.tsx
1import{ useMemo, useReducer, useRef }from"react";23type FileNode={id: string; type:"file"; name: string };4type FolderNode={id: string; type:"folder"; name: string; childIds: string[]};5type Node=FileNode|FolderNode;67type State={8nodes:Record<string,Node>;// flat: { id -> node }9rootIds: string[];10expanded:Set<string>;11focusedId: string |null;12editingId: string |null;13};1415type Action=16|{type:"toggle"; id: string }17|{type:"focus"; id: string }18|{type:"startRename"; id: string }19|{type:"commitRename"; id: string; name: string }20|{type:"cancelRename"}21|{type:"delete"; id: string; nextFocusId: string |null};2223functionreducer(state:State,action:Action):State{24switch(action.type){25case"toggle":{26const expanded =newSet(state.expanded);27if(expanded.has(action.id)) expanded.delete(action.id);28else expanded.add(action.id);29return{...state, expanded };30}31case"focus":32return{...state,focusedId: action.id};33case"startRename":34return{...state,editingId: action.id};35case"cancelRename":36return{...state,editingId:null};37case"commitRename":{38const node = state.nodes[action.id];39const name = action.name.trim();40if(!node ||!name)return{...state,editingId:null};41return{42...state,43nodes:{...state.nodes,[action.id]:{...node, name }},44editingId:null,45};46}47case"delete":{48const doomed =newSet<string>();49constcollect=(id: string)=>{50 doomed.add(id);51const node = state.nodes[id];52if(node?.type ==="folder") node.childIds.forEach(collect);53};54collect(action.id);55constnodes:Record<string,Node>={};56for(const[id, node]ofObject.entries(state.nodes)){57if(doomed.has(id))continue;58 nodes[id]= node.type==="folder"59?{...node,childIds: node.childIds.filter((c)=>!doomed.has(c))}60: node;61}62return{63...state,64 nodes,65rootIds: state.rootIds.filter((id)=>!doomed.has(id)),66focusedId: action.nextFocusId,67editingId:null,68};69}70}71}7273// Depth-first list of only the rows currently visible on screen.74functionvisibleRows(state:State){75constrows:{id: string; level: number }[]=[];76const seen =newSet<string>();77constwalk=(id: string,level: number)=>{78if(seen.has(id))return;// guard against malformed cycles79 seen.add(id);80 rows.push({ id, level });81const node = state.nodes[id];82if(node?.type ==="folder"&& state.expanded.has(id)){83 node.childIds.forEach((c)=>walk(c, level +1));84}85};86 state.rootIds.forEach((id)=>walk(id,0));87return rows;88}8990exportfunctionFileExplorer({ initial }:{initial:State}){91const[state, dispatch]=useReducer(reducer, initial);92const rows =useMemo(()=>visibleRows(state),[state]);93const containerRef = useRef<HTMLDivElement>(null);9495const focusedId = state.focusedId?? rows[0]?.id ??null;96const index = rows.findIndex((r)=> r.id=== focusedId);9798functionfocusRow(id: string |null){99if(!id)return;100dispatch({type:"focus", id });101requestAnimationFrame(()=>102 containerRef.current103?.querySelector<HTMLElement>('[data-id="'+ id +'"]')104?.focus(),105);106}107108functiononKeyDown(event:React.KeyboardEvent,id: string){109if(state.editingId)return;// the text input owns the keyboard while renaming110const node = state.nodes[id];111const isOpenFolder = node?.type ==="folder"&& state.expanded.has(id);112switch(event.key){113case"ArrowDown":114 event.preventDefault();115focusRow(rows[Math.min(index +1, rows.length-1)]?.id ??null);116break;117case"ArrowUp":118 event.preventDefault();119focusRow(rows[Math.max(index -1,0)]?.id ??null);120break;121case"ArrowRight":122 event.preventDefault();123if(node?.type ==="folder"&&!isOpenFolder)dispatch({type:"toggle", id });124elseif(isOpenFolder)focusRow(rows[index +1]?.id ??null);125break;126case"ArrowLeft":127 event.preventDefault();128if(isOpenFolder)dispatch({type:"toggle", id });129else{130const here = rows[index].level;131const parent = rows.slice(0, index).reverse().find((r)=> r.level< here);132if(parent)focusRow(parent.id);133}134break;135case"F2":136 event.preventDefault();137dispatch({type:"startRename", id });138break;139case"Delete":{140 event.preventDefault();141const next = rows[index +1]?.id ?? rows[index -1]?.id ??null;142dispatch({type:"delete", id,nextFocusId: next });143focusRow(next);144break;145}146}147}148149return(150<div151 ref={containerRef}152 role="tree"153 aria-label="Files"154 onKeyDown={(event)=> focusedId &&onKeyDown(event, focusedId)}155>156{rows.map(({ id, level })=>{157const node = state.nodes[id];158const isFolder = node.type==="folder";159return(160<div161 key={id}162 data-id={id}163 role="treeitem"164 aria-level={level +1}165 aria-expanded={isFolder ? state.expanded.has(id):undefined}166 tabIndex={id === focusedId ?0:-1}167 style={{paddingLeft: level *16}}168 onClick={()=>{169focusRow(id);170if(isFolder)dispatch({type:"toggle", id });171}}172>173{state.editingId=== id ?(174<RenameInput175 initial={node.name}176 onCommit={(name)=>{dispatch({type:"commitRename", id, name });focusRow(id);}}177 onCancel={()=>{dispatch({type:"cancelRename"});focusRow(id);}}178/>179):(180<span>181{isFolder ?(state.expanded.has(id)?"\u25be ":"\u25b8 "):""}182{node.name}183</span>184)}185</div>186);187})}188</div>189);190}191192functionRenameInput({ initial, onCommit, onCancel }:{193initial: string;onCommit:(name: string)=>void;onCancel:()=>void;194}){195const ref = useRef<HTMLInputElement>(null);196return(197<input198 ref={ref}199 autoFocus200 defaultValue={initial}201 onKeyDown={(event)=>{202 event.stopPropagation();// do not let Enter/Escape reach the tree203if(event.key==="Enter")onCommit(ref.current!.value);204if(event.key==="Escape")onCancel();205}}206 onBlur={()=>onCommit(ref.current!.value)}207/>208);209}
How each part works
Nodes are stored flat, keyed by id
state.nodes is a plain object like { a: {...}, b: {...} }. Folders do not contain their children; they hold a list of child ids. Renaming a node is a one-line object update, and delete does not require rebuilding a deep nested structure.
visibleRows turns the tree into a flat list
It walks from the roots and only steps into a folder if it is expanded. The result is exactly what you see, top to bottom, with a level number for indentation. Arrow Up and Down just move one position in this list — they never touch storage order.
One reducer owns every change
toggle, focus, rename, and delete are all messages sent to reducer. There is one place to read to understand how state can change, and every branch returns a brand-new state object rather than mutating, so React re-renders reliably.
Delete collects descendants first
collect recursively gathers the node and everything under it into a doomed set. Then we rebuild nodes skipping those and strip their ids out of any parent's child list. The next row to focus (next sibling, then previous, then nothing) is chosen before the delete so focus never disappears.
Roving tabindex
Only the focused row has tabIndex 0; every other row has -1. Pressing Tab therefore jumps past the whole tree in one step, like a real file tree, and arrow keys move within it. focusRow updates which id is focused and then actually calls .focus() on that row's DOM node.
Right and Left are folder-aware
Right on a closed folder opens it; on an open folder it moves to the first child. Left on an open folder closes it; on anything else it jumps to the parent row, found by scanning backward for the first row with a smaller level number.
Rename is a separate mode
While editingId is set, the tree's key handler returns immediately at the top, so arrow keys type into the box instead of navigating. The input calls stopPropagation so its Enter and Escape do not leak to the tree. Enter commits, Escape cancels, blur commits, and focus then returns to the row.
Why this is correct
Flat, id-keyed storage keeps edits and deletes simple and lets expansion, focus, and selection reference stable ids instead of copied objects.
The visible-rows list is derived from expansion state, and all keyboard movement is defined against that list, not raw storage order.
A tree is one Tab stop: roving tabindex plus explicit .focus() calls produce correct composite-widget behavior.
Delete must compute the surviving focus target before removing anything, or focus is lost when the row disappears.
Rename is a mode that suspends tree key handling, so the text input receives the keyboard cleanly.
05Testing strategy
Critical behavior
Tree renders and recurses correctly for arbitrary depth without key warnings.
Expand/collapse state persists correctly per node across unrelated re-renders.
Rename/delete update the underlying data structure immutably.
Keyboard tree navigation matches the ARIA treeview pattern reasonably closely.
Failure and boundary cases
Renaming to an empty string or a name that collides with a sibling should be rejected with feedback.
Deleting the currently-selected node should clear or move the selection sensibly.
Very deep nesting shouldn't break horizontal layout on narrow screens.
Expand-all/collapse-all must handle cycles gracefully if the data is ever malformed (defensive, not required to support cycles).
Accessibility
Model the tree with role="tree", role="treeitem", and aria-expanded on folder nodes.
Arrow keys should navigate the tree (up/down between visible rows, right/left to expand/collapse or move to parent).
Rename inputs should trap Enter (confirm) and Escape (cancel) without submitting a surrounding form.
06Performance and production hardening
Memoize visible flattening by node and expansion revisions.
Update normalized records touched by an operation, not every branch.
Lazy-load folder children with independent statuses.
Virtualize only with correct tree position and focus semantics.
QAInterview questions and model answers
Answer aloud first. Then open the model answer and compare state ownership, failure handling, accessibility, and trade-offs—not exact wording.
Model answer
It makes lookup and edits direct and lets expansion and selection reference stable ids instead of copying recursive node objects.