Skip to content
Intermediate50 min build target6 min guide

File explorer

A collapsible, nested folder/file tree with expand-all, rename, and delete interactions.

File explorer interface reference

HOW TO USE THIS CHALLENGE

  1. 1. Read the briefClarify decisions before coding.
  2. 2. Build from memoryUse the 50-minute target.
  3. 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.

TypeScript
type Node =  | { id: string; type: 'file'; name: string; parentId: string }  | { id: string; type: 'folder'; name: string; parentId: string | null; childIds: string[] };type ExplorerState = {  nodes: Record<string, Node>; rootIds: string[]; expanded: Set<string>;  focusedId: string | null; selectedId: string | null;  editing: null | { id: string; draft: string; error?: string };};

03Component architecture

  1. 01Explorer reducer owns normalized domain operations.
  2. 02Tree exposes one composite Tab stop.
  3. 03Recursive TreeNode renders hierarchy and groups.
  4. 04flattenVisible produces keyboard order from expansion state.
  5. 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.

TypeScript
function visibleIds(state: ExplorerState) {  const out: string[] = [], seen = new Set<string>();  const visit = (id: string) => {    if (seen.has(id)) return; seen.add(id); out.push(id);    const node = state.nodes[id];    if (node?.type === 'folder' && state.expanded.has(id)) node.childIds.forEach(visit);  };  state.rootIds.forEach(visit); return out;}

Step 2

Implement tree keyboard rules

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.

TSX
<div role="tree" aria-label="Project files">  <div role="treeitem" aria-level={level}    aria-expanded={folder ? expanded : undefined}    aria-selected={selected} tabIndex={focused ? 0 : -1}>...</div></div>

Step 3

Apply atomic immutable operations

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
import { useMemo, useReducer, useRef } from "react";
type FileNode = { id: string; type: "file"; name: string };type FolderNode = { id: string; type: "folder"; name: string; childIds: string[] };type Node = FileNode | FolderNode;
type State = {  nodes: Record<string, Node>;   // flat: { id -> node }  rootIds: string[];  expanded: Set<string>;  focusedId: string | null;  editingId: string | null;};
type Action =  | { type: "toggle"; id: string }  | { type: "focus"; id: string }  | { type: "startRename"; id: string }  | { type: "commitRename"; id: string; name: string }  | { type: "cancelRename" }  | { type: "delete"; id: string; nextFocusId: string | null };
function reducer(state: State, action: Action): State {  switch (action.type) {    case "toggle": {      const expanded = new Set(state.expanded);      if (expanded.has(action.id)) expanded.delete(action.id);      else expanded.add(action.id);      return { ...state, expanded };    }    case "focus":      return { ...state, focusedId: action.id };    case "startRename":      return { ...state, editingId: action.id };    case "cancelRename":      return { ...state, editingId: null };    case "commitRename": {      const node = state.nodes[action.id];      const name = action.name.trim();      if (!node || !name) return { ...state, editingId: null };      return {        ...state,        nodes: { ...state.nodes, [action.id]: { ...node, name } },        editingId: null,      };    }    case "delete": {      const doomed = new Set<string>();      const collect = (id: string) => {        doomed.add(id);        const node = state.nodes[id];        if (node?.type === "folder") node.childIds.forEach(collect);      };      collect(action.id);      const nodes: Record<string, Node> = {};      for (const [id, node] of Object.entries(state.nodes)) {        if (doomed.has(id)) continue;        nodes[id] = node.type === "folder"          ? { ...node, childIds: node.childIds.filter((c) => !doomed.has(c)) }          : node;      }      return {        ...state,        nodes,        rootIds: state.rootIds.filter((id) => !doomed.has(id)),        focusedId: action.nextFocusId,        editingId: null,      };    }  }}
// Depth-first list of only the rows currently visible on screen.function visibleRows(state: State) {  const rows: { id: string; level: number }[] = [];  const seen = new Set<string>();  const walk = (id: string, level: number) => {    if (seen.has(id)) return;   // guard against malformed cycles    seen.add(id);    rows.push({ id, level });    const node = state.nodes[id];    if (node?.type === "folder" && state.expanded.has(id)) {      node.childIds.forEach((c) => walk(c, level + 1));    }  };  state.rootIds.forEach((id) => walk(id, 0));  return rows;}
export function FileExplorer({ initial }: { initial: State }) {  const [state, dispatch] = useReducer(reducer, initial);  const rows = useMemo(() => visibleRows(state), [state]);  const containerRef = useRef<HTMLDivElement>(null);
  const focusedId = state.focusedId ?? rows[0]?.id ?? null;  const index = rows.findIndex((r) => r.id === focusedId);
  function focusRow(id: string | null) {    if (!id) return;    dispatch({ type: "focus", id });    requestAnimationFrame(() =>      containerRef.current        ?.querySelector<HTMLElement>('[data-id="' + id + '"]')        ?.focus(),    );  }
  function onKeyDown(event: React.KeyboardEvent, id: string) {    if (state.editingId) return; // the text input owns the keyboard while renaming    const node = state.nodes[id];    const isOpenFolder = node?.type === "folder" && state.expanded.has(id);    switch (event.key) {      case "ArrowDown":        event.preventDefault();        focusRow(rows[Math.min(index + 1, rows.length - 1)]?.id ?? null);        break;      case "ArrowUp":        event.preventDefault();        focusRow(rows[Math.max(index - 1, 0)]?.id ?? null);        break;      case "ArrowRight":        event.preventDefault();        if (node?.type === "folder" && !isOpenFolder) dispatch({ type: "toggle", id });        else if (isOpenFolder) focusRow(rows[index + 1]?.id ?? null);        break;      case "ArrowLeft":        event.preventDefault();        if (isOpenFolder) dispatch({ type: "toggle", id });        else {          const here = rows[index].level;          const parent = rows.slice(0, index).reverse().find((r) => r.level < here);          if (parent) focusRow(parent.id);        }        break;      case "F2":        event.preventDefault();        dispatch({ type: "startRename", id });        break;      case "Delete": {        event.preventDefault();        const next = rows[index + 1]?.id ?? rows[index - 1]?.id ?? null;        dispatch({ type: "delete", id, nextFocusId: next });        focusRow(next);        break;      }    }  }
  return (    <div      ref={containerRef}      role="tree"      aria-label="Files"      onKeyDown={(event) => focusedId && onKeyDown(event, focusedId)}    >      {rows.map(({ id, level }) => {        const node = state.nodes[id];        const isFolder = node.type === "folder";        return (          <div            key={id}            data-id={id}            role="treeitem"            aria-level={level + 1}            aria-expanded={isFolder ? state.expanded.has(id) : undefined}            tabIndex={id === focusedId ? 0 : -1}            style={{ paddingLeft: level * 16 }}            onClick={() => {              focusRow(id);              if (isFolder) dispatch({ type: "toggle", id });            }}          >            {state.editingId === id ? (              <RenameInput                initial={node.name}                onCommit={(name) => { dispatch({ type: "commitRename", id, name }); focusRow(id); }}                onCancel={() => { dispatch({ type: "cancelRename" }); focusRow(id); }}              />            ) : (              <span>                {isFolder ? (state.expanded.has(id) ? "\u25be " : "\u25b8 ") : ""}                {node.name}              </span>            )}          </div>        );      })}    </div>  );}
function RenameInput({ initial, onCommit, onCancel }: {  initial: string; onCommit: (name: string) => void; onCancel: () => void;}) {  const ref = useRef<HTMLInputElement>(null);  return (    <input      ref={ref}      autoFocus      defaultValue={initial}      onKeyDown={(event) => {        event.stopPropagation(); // do not let Enter/Escape reach the tree        if (event.key === "Enter") onCommit(ref.current!.value);        if (event.key === "Escape") onCancel();      }}      onBlur={() => onCommit(ref.current!.value)}    />  );}

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.

Primary references

Ready to build it?

Implement the brief above in your own environment against a timer close to 50 minutes, then self-review against the rubric before moving on.

Back to all briefs →