Skip to the solution
HardFrontend13 min read

Build a File Explorer with Nested Drag-and-Drop

1.2k views

The problem

Question presented to candidate: "Our file tree can already expand, collapse and edit. Now let users reorganise it by dragging: drop a file or folder onto a folder to move it inside, or near the top or bottom edge of any row to place it before or after. Make sure nobody can drop a folder into itself or into one of its own subfolders, and give keyboard users a way to move things too."

Requirements:

  • Store the tree normalized: id -> { name, type, parentId, childIds }, built from nested JSON on load; manual order is kept in childIds.
  • HTML5 drag-and-drop on each row: dragstart, dragover (decide and show the drop position), drop, dragend (clean up).
  • Drop position from the pointer inside the row: folders use top quarter = before, bottom quarter = after, middle = inside; files use top half / bottom half.
  • Reject a drop whose new parent is the dragged node or any of its descendants, both while hovering (no preventDefault, invalid indicator) and again on drop.
  • Reject a name clash in the destination folder. Open the folder after an inside drop. Announce every result in a live region.
  • Keyboard alternative: Ctrl+X on a row, Ctrl+V on a folder (inside) or file (after), Escape cancels; Up/Down move focus.

What a strong answer should cover:

  • Why normalized state makes a move O(depth) and two small edits, compared with remove-and-reinsert on nested data.
  • The descendant check as a walk up the parentId chain.
  • The dragover contract: calling preventDefault() is what marks a valid drop target; dataTransfer data cannot be read during dragover, so the dragged id is kept in app state.
  • Drop indicators and cleanup on dragend (also when the drop happens outside).
  • An accessible alternative to dragging, and how to test drag-and-drop when the test DOM has no DataTransfer.

Clarifying questions expected:

  • "Is reordering inside a folder needed, or only moving between folders?"
  • "Should a move be persisted to a server, and what if it fails?"
  • "Touch support: native drag-and-drop does not work on most mobile browsers; do we need pointer events?"

Code / implementation expected: Yes. The move logic with the descendant guard is the core; working drag handlers and a keyboard alternative are expected at this level.

  • React
  • Tree
  • Drag and Drop
  • File Explorer
  • DnD

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

13 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 { useRef, useState } from "react";
import TreeRow from "./src/components/TreeRow";
import {
  ROOT,
  initialNodes,
  dropPosition,
  isSelfOrAncestor,
  moveNode,
  describeMove,
  visibleIds,
} from "./src/utils/normalized";

export default function App() {
  const [nodes, setNodes] = useState(initialNodes);
  const [expanded, setExpanded] = useState(
    () => new Set(["src", "src/components", "docs"]),
  );
  const [dragId, setDragId] = useState(null); // for styling the dragged row
  const [dropTarget, setDropTarget] = useState(null); // { id, position, valid }
  const [cutId, setCutId] = useState(null); // keyboard move: Ctrl+X then Ctrl+V
  const [focusId, setFocusId] = useState(nodes[ROOT].childIds[0]);
  const [status, setStatus] = useState("");
  const treeRef = useRef(null);
  // During dragover the browser hides
  // dataTransfer data (protected mode), so the
  // id being dragged is kept here instead of read back from the event.
  const dragRef = useRef(null);

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

  function doMove(from, targetId, position) {
    const result = moveNode(nodes, from, targetId, position);
    if (result.error) {
      setStatus(result.error);
      return false;
    }
    if (result.nodes === nodes) return false;
    setNodes(result.nodes);
    if (position === "inside")
      setExpanded((prev) => new Set(prev).add(targetId));
    setStatus(describeMove(nodes, from, targetId, position));
    return true;
  }

  function measure(e, id) {
    const r = e.currentTarget.getBoundingClientRect();
    const position = dropPosition(nodes[id], (e.clientY - r.top) / r.height);
    const parentId = position === "inside" ? id : nodes[id].parentId;
    const valid =
      dragRef.current !== id &&
      !isSelfOrAncestor(nodes, dragRef.current, parentId);
    return { id, position, valid };
  }

  function onDragStart(e, id) {
    // Firefox will not start a drag without data
    e.dataTransfer.setData("text/plain", nodes[id].name);
    e.dataTransfer.effectAllowed = "move";
    dragRef.current = id;
    setDragId(id);
  }

  function onDragOver(e, id) {
    if (!dragRef.current) return;
    const t = measure(e, id);
    if (t.valid) {
      e.preventDefault();
      e.dataTransfer.dropEffect = "move";
    } // preventDefault = "drop allowed here"
    setDropTarget((prev) =>
      prev &&
      prev.id === t.id &&
      prev.position === t.position &&
      prev.valid === t.valid
        ? prev
        : t,
    );
  }

  function onDrop(e, id) {
    e.preventDefault();
    if (dragRef.current) {
      const t = measure(e, id);
      doMove(dragRef.current, id, t.position);
    }
    onDragEnd();
  }

  function onDragEnd() {
    dragRef.current = null;
    setDragId(null);
    setDropTarget(null);
  }

  function focusRow(id) {
    setFocusId(id);
    treeRef.current.querySelector(`[data-id="${id}"]`)?.focus();
  }

  function onKeyDown(e, id) {
    const ids = visibleIds(nodes, expanded);
    const i = ids.indexOf(id);
    const mod = e.ctrlKey || e.metaKey;
    if (e.key === "ArrowDown" && ids[i + 1]) focusRow(ids[i + 1]);
    else if (e.key === "ArrowUp" && ids[i - 1]) focusRow(ids[i - 1]);
    else if (e.key === "Enter" && nodes[id].type === "folder") toggle(id);
    else if (mod && e.key.toLowerCase() === "x") {
      setCutId(id);
      setStatus(
        "Cut " +
          nodes[id].name +
          ". Focus a folder and press Ctrl+V to move it there.",
      );
    } else if (mod && e.key.toLowerCase() === "v" && cutId) {
      const position = nodes[id].type === "folder" ? "inside" : "after";
      if (doMove(cutId, id, position)) setCutId(null);
    } else if (e.key === "Escape" && cutId) {
      setCutId(null);
      setStatus("Move cancelled");
    } else return;
    e.preventDefault();
  }

  const api = {
    nodes,
    expanded,
    dragId,
    dropTarget,
    cutId,
    focusId,
    setFocusId,
    toggle,
    onKeyDown,
    onDragStart,
    onDragOver,
    onDrop,
    onDragEnd,
  };

  return (
    <div className="app">
      <h1>Files</h1>
      <p className="hint">
        Drag a row onto a folder to move it inside, or near a top or bottom edge
        to reorder. Keyboard: Ctrl+X, then Ctrl+V on a folder.
      </p>
      <ul role="tree" aria-label="Project files" className="tree" ref={treeRef}>
        {nodes[ROOT].childIds.map((id) => (
          <TreeRow key={id} id={id} depth={1} 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 79 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 nextHard

Build a Virtualized List

Continue