Skip to the solution
MediumMachine Coding14 min read

Build a File Explorer (tree view)

734 views

The problem

Question presented to candidate: "Build the file tree you see on the left of a code editor. Given nested JSON of folders and files, render it with indentation, let me open and close folders, select an item, and add, rename and delete files and folders. Keyboard users should be able to move around it with the arrow keys."

Requirements:

  • Render folders and files recursively from nested data, indented by depth, folders before files, A to Z.
  • Click (or Enter) on a folder toggles it open or closed; the open state is kept separately from the data.
  • Toolbar actions on the selection: New file, New folder (inside the selected folder, or next to the selected file), Rename, Delete (a folder deletes everything inside it).
  • New and rename use an inline text box: Enter commits, Escape cancels, empty cancels, a name already used in that folder shows an error and keeps the box open.
  • ARIA tree pattern: role="tree" / treeitem / group, aria-expanded, aria-level, one tab stop, Up/Down/Left/Right, F2 to rename, Delete to delete.

What a strong answer should cover:

  • A recursive component that renders one node and calls itself for each child, with a stable key per id.
  • The state model: tree data, a set of expanded ids, the selected id and the "editing" draft are separate; visible rows and the selected node are derived.
  • Immutable updates by id (updateNode, insertNode, removeNode) that copy only the path to the change, or in-place mutation where the framework tracks it (Vue).
  • Nested vs normalized data: why nested is fine here and when a flat id -> node map wins (moves, big trees).
  • Keyboard and focus: roving tabindex, moving DOM focus after state changes, ignoring keys typed into the inline input.
  • Validation and edge cases: duplicate names (case-insensitive), deleting the selected folder, creating inside a closed folder.

Clarifying questions expected:

  • "Is the whole tree loaded up front, or should folders load their children lazily?"
  • "How large can a folder get? Do we need virtualization?"
  • "Are names unique per folder, and is the comparison case-sensitive?"

Code / implementation expected: Yes. A working component with recursion, expand/collapse and add/rename/delete; keyboard navigation is a strong plus.

  • React
  • Tree
  • Recursion

Try it yourself

Sketch your approach before you read the solution. That is what the interview tests.

Stuck? Get a hint.

A short nudge toward the approach. It will not give the answer away.

Solution

The solution is hidden so you can try first

14 min read Β· 9 sections

React solution

Every file of the working solution. Open it in the playground to run and change it.

ReactComplete solution
import { useEffect, useMemo, useRef, useState } from "react";
import TreeNode, { NewItemRow } from "./src/components/TreeNode";
import {
  initialTree,
  makeId,
  sortNodes,
  findNode,
  findParentId,
  updateNode,
  removeNode,
  insertNode,
  countDescendants,
  visibleRows,
  validateName,
} from "./src/utils/treeUtils";

export default function App() {
  const [tree, setTree] = useState(initialTree);
  const [expanded, setExpanded] = useState(() => new Set());
  const [selectedId, setSelectedId] = useState(null);
  // { kind: 'new', parentId, type } | { kind: 'rename', id }
  const [editing, setEditing] = useState(null);
  const [error, setError] = useState("");
  const [status, setStatus] = useState("");
  const treeRef = useRef(null);
  const focusPending = useRef(false);

  // Derived, never stored: the visible rows
  // (for the keyboard) and the selected node.
  const rows = useMemo(() => visibleRows(tree, expanded), [tree, expanded]);
  const selected = selectedId ? findNode(tree, selectedId) : null;
  const tabStopId = rows.some((r) => r.id === selectedId)
    ? selectedId
    : rows[0]?.id;

  // After a keyboard move, put DOM focus on the newly selected row.
  useEffect(() => {
    if (!focusPending.current || !selectedId) return;
    focusPending.current = false;
    treeRef.current.querySelector(`[data-id="${selectedId}"]`)?.focus();
  });

  function setOpen(id, open) {
    setExpanded((prev) => {
      const next = new Set(prev);
      if (open) next.add(id);
      else next.delete(id);
      return next;
    });
  }

  function select(id) {
    setSelectedId(id);
    focusPending.current = true;
  }

  function startNew(type) {
    let parentId = null;
    if (selected)
      parentId =
        selected.type === "folder"
          ? selected.id
          : findParentId(tree, selected.id);
    if (parentId) setOpen(parentId, true);
    setEditing({ kind: "new", parentId, type });
    setError("");
  }

  function startRename(node) {
    if (!node) return;
    setSelectedId(node.id);
    setEditing({ kind: "rename", id: node.id });
    setError("");
  }

  function cancel() {
    setEditing(null);
    setError("");
    focusPending.current = true;
  }

  function commit(raw) {
    const name = raw.trim();
    if (!name) {
      cancel();
      return;
    }
    const parentId =
      editing.kind === "new"
        ? editing.parentId
        : findParentId(tree, editing.id);
    const problem = validateName(
      tree,
      parentId,
      name,
      editing.kind === "rename" ? editing.id : null,
    );
    if (problem) {
      setError(problem);
      return;
    }
    if (editing.kind === "new") {
      const node = { id: makeId(), name, type: editing.type };
      if (editing.type === "folder") node.children = [];
      setTree((t) => insertNode(t, parentId, node));
      setSelectedId(node.id);
      setStatus("Created " + name);
    } else {
      setTree((t) => updateNode(t, editing.id, (n) => ({ ...n, name })));
      setStatus("Renamed to " + name);
    }
    setEditing(null);
    setError("");
    focusPending.current = true;
  }

  function remove(node) {
    if (!node) return;
    const inside = countDescendants(node);
    const parentId = findParentId(tree, node.id);
    setTree((t) => removeNode(t, node.id));
    setSelectedId(parentId);
    focusPending.current = true;
    setStatus(
      "Deleted " +
        node.name +
        (inside
          ? " and " + inside + " item" + (inside === 1 ? "" : "s") + " inside"
          : ""),
    );
  }

  function onRowClick(node) {
    setSelectedId(node.id);
    if (node.type === "folder") setOpen(node.id, !expanded.has(node.id));
  }

  function onKeyDown(e, id) {
    const i = rows.findIndex((r) => r.id === id);
    const { node, parentId } = rows[i];
    const isFolder = node.type === "folder";
    const open = expanded.has(id);
    if (e.key === "ArrowDown") {
      if (rows[i + 1]) select(rows[i + 1].id);
    } else if (e.key === "ArrowUp") {
      if (rows[i - 1]) select(rows[i - 1].id);
    } else if (e.key === "ArrowRight") {
      if (isFolder && !open) setOpen(id, true);
      else if (isFolder && node.children.length) select(rows[i + 1].id);
    } else if (e.key === "ArrowLeft") {
      if (isFolder && open) setOpen(id, false);
      else if (parentId) select(parentId);
    } else if (e.key === "Enter") {
      setSelectedId(id);
      if (isFolder) setOpen(id, !open);
    } else if (e.key === "F2") startRename(node);
    else if (e.key === "Delete") remove(node);
    else return;
    e.preventDefault();
  }

  const api = {
    expanded,
    selectedId,
    tabStopId,
    editing,
    error,
    onRowClick,
    onKeyDown,
    commit,
    cancel,
  };

  return (
    <div className="app">
      <h1>Files</h1>
      <div className="toolbar" role="toolbar" aria-label="File actions">
        <button data-testid="new-file" onClick={() => startNew("file")}>
          + File
        </button>
        <button data-testid="new-folder" onClick={() => startNew("folder")}>
          + Folder
        </button>
        <button
          data-testid="rename"
          disabled={!selected}
          onClick={() => startRename(selected)}
        >
          Rename
        </button>
        <button
          data-testid="delete"
          disabled={!selected}
          onClick={() => remove(selected)}
        >
          Delete
        </button>
      </div>
      <ul role="tree" aria-label="Project files" className="tree" ref={treeRef}>
        {sortNodes(tree).map((node) => (
          <TreeNode key={node.id} node={node} depth={1} api={api} />
        ))}
        {editing?.kind === "new" && editing.parentId === null && (
          <NewItemRow depth={1} type={editing.type} api={api} />
        )}
      </ul>
      <p className="status" role="status" data-testid="status">
        {status}
      </p>
    </div>
  );
}

Discussion

Other approaches, follow-ups and how it went in real interviews.

Sign in to join the discussion.

No comments yet. Share how you would answer it.

Question 46 of 107 in Machine coding

Keep your momentum going.

Mark this question solved to track your progress, then take the next one while it is fresh.

Up nextMedium

Build Infinite Scroll with IntersectionObserver & Skeleton

Continue