Design a file-explorer tree view
A reusable folder tree like the explorer in VS Code or the sidebar in Google Drive. Showing nested folders is easy; the hard parts are a flat data shape that makes moves and lazy loading cheap, loading children on expand with per-folder errors, the full tree keyboard and screen reader pattern, multi-select in tree order, virtualizing 100,000 rows, drag to move with rollback, inline rename, filtering, and live updates that don't lose your place.
The question
Design a tree view component for our design system. The first users are our web code editor, which shows a project's files, and our document app, which shows nested folders shared by a team.
Walk me through how you store the tree, how folders load, how keyboard and screen reader users move through it, how selection and drag to move work, and how it stays fast with a hundred thousand files.
Real-life examples: The file explorer in VS Code, The folder sidebar in Google Drive, The file tree on a GitHub pull request or repository page, The layers panel in Figma or another design tool, The page tree in the Notion sidebar
Requirements: what the tree must do, and for whom
A tree view shows items that contain other items. Each row is a node. A node that can hold children is a branch (a folder); one that can't is a leaf (a file). You click the small arrow next to a folder to expand it and see its children indented one step further, and click again to collapse it.
Every desktop file manager has one, and so do VS Code's explorer, the Google Drive sidebar, GitHub's file tree and the layers panel of a design tool. That's why interviewers like it: everyone has used one, and the gap between a nested list of ul elements and a tree that works for 100,000 files, a keyboard user and two people moving folders at once is large.
Questions to ask first
| Question | Answer we assume | What it changes |
|---|---|---|
| How big can a tree get? | Up to 200,000 files in 30,000 folders; one folder can hold 10,000 children | We can't load or render it all. Children load on expand, and the visible rows are virtualized. |
| Is the whole tree known up front? | No. The server returns one folder's children at a time | Each folder has its own loading and error state. Counts are unknown until a folder loads. |
| Who uses it? | Mouse, touch, keyboard and screen reader users | We follow the WAI-ARIA tree pattern with the full keyboard model. |
| Single or multi-select? | Both, chosen by a prop | Ctrl and Shift selection in tree order, and aria-multiselectable. |
| Can users change the tree? | Yes: move by drag or keyboard, rename inline, create and delete | Optimistic moves with rollback, and rules that block impossible moves. |
| Do others edit it at the same time? | Yes, a shared team folder | Live insert, move and delete events that must not move the user's focus or scroll. |
| Who builds the UI on top? | Several product teams | A headless core with a thin default component, and an async getChildren contract. |
Functional requirements
- Show nested folders and files with indentation, an expand arrow on folders, an icon and a name.
- Expand and collapse folders. Children load from the server the first time a folder opens, with a loading row and a retry on error.
- Remember which folders are open, per user, across reloads.
- Select one node, or many with Ctrl or Cmd for single toggles and Shift for ranges.
- Move nodes by drag and drop, before, after or inside a target, and by a keyboard "Move to" command.
- Rename inline with F2, with Enter to save and Escape to cancel.
- Filter the tree by name, showing matches with their ancestors.
- Show changes made by other people while you browse.
Non-functional requirements
- Fast with huge trees: expanding a folder of 10,000 children stays under 100 milliseconds of main thread work, and scrolling holds 60 frames per second. Only about 40 rows are in the DOM, whatever the tree size.
- Quick to respond: a cached folder opens in the same frame; a folder that must load shows its loading row within one frame and its children in under 300 milliseconds on a normal connection.
- Accessible: every action works by keyboard, and a screen reader hears each node's name, level, position and expanded state.
- Safe edits: a failed move or rename puts the tree back and says why. No edit can create a cycle or lose a file.
- Reusable: the core logic works with any data source and any row design.
Out of scope
- The file contents, previews and the editor itself: the tree only shows names and emits events.
- The grid and list views of a folder's contents, which the Google Drive guide covers.
- Uploading files by dropping them from the desktop: the file uploader guide covers that. Our drag is for moving nodes already in the tree.
Check your understanding · Exercise 1
Which answers from the interviewer change the design the most? Select all that apply.
What makes a tree view hard
A list has one order: row 5 comes after row 4. A tree has two directions at once: each node has a parent and maybe children (up and down a level), and siblings in order (within a level). The screen shows a third thing: a flat column of rows that mixes levels. Almost every hard part of this problem comes from translating between those shapes.
- It is partial. You never hold the whole tree. A folder you haven't opened has unknown children, maybe zero, maybe 10,000. Every operation must handle "not loaded yet".
- It is deep and uneven. A path like
src/app/(dashboard)/settings/billing/page.tsxis 6 levels down, whilenode_modulesnext to it holds thousands of folders. Recursion depth and row counts are both unbounded. - The visible rows are derived. What you see depends on which folders are open. Expand one folder and 400 rows appear in the middle of the column; every row below shifts. The rendering must be cheap to recompute.
- Keyboard and screen reader rules are strict. The WAI-ARIA tree pattern defines what each arrow key does, and a screen reader needs each row's level and position in its set even when most rows aren't in the DOM.
- It is edited. Moves change two parents at once and can create a cycle if you drop a folder into its own child. Renames can collide with a sibling's name. Each edit must be undoable if the server says no.
- It is shared. Other people's changes arrive while you have folders open, keyboard focus on a row, and the panel scrolled halfway down.
Keep these six in mind. Each of the next chapters takes one and makes a decision, starting with the one everything else rests on: how the tree is stored.
Think first
The naive version is a recursive component: Folder renders its name and, if open, maps over children and renders Folder again. It works in a demo with 50 nodes. List what breaks first at 200,000 files.
Opening node_modules renders thousands of DOM nodes in one go, so the frame freezes for hundreds of milliseconds.
Each component holds its own isOpen in local state, so "collapse all", "reveal this file" and persisting open folders need to reach into every component.
Moving a node means finding it inside a nested object, copying every ancestor to keep data immutable, and doing the same for the destination path.
Keyboard navigation needs "the next visible row", which a recursive render never computes in one place.
Data shape: nested children or a flat map of nodes
The first instinct is to store the tree the way you draw it: each folder object holds an array of child objects. That matches a JSON file and a recursive component. Let's see what it costs on three everyday operations in a tree with 30,000 folders.
- Find a node by id. A server event says file
f-9124was renamed. With nesting you search the tree: up to 200,000 visits in the worst case, each time an event arrives. - Move a node. Drag
utilsfromsrc/libtosrc/shared. You remove it from one nested array and add it to another. With immutable state (React needs new object references to see a change), you copy every ancestor on both paths. - Load children lazily. The children of
src/libarrive. You must walk down tosrc/liband replace itschildrenarray, again copying the path.
Decision How should the client store the tree?
What we pick: We store a flat, normalized map: nodes by id, each with parentId, and folders with ordered childIds (see the normalization lesson). It makes the operations we do most (lookups from events, moves, lazy loads) cost a few record updates instead of a tree walk, and it matches the one-parent-per-item model of the Google Drive guide.
Map.get. A null in childIds (hatched) means "not loaded yet", which is different from an empty array.Three states for a folder's children
Because the tree is partial, childIds alone can't tell "empty" from "not loaded". We keep childIds as null until the folder loads, and a separate hasChildren hint from the server so we know whether to draw the arrow at all. A folder whose hasChildren is false shows no arrow and never fetches.
type NodeId = string;
type TreeNode = {
id: NodeId;
parentId: NodeId | null;
name: string;
kind: "folder" | "file";
hasChildren: boolean; // server hint: draw the arrow?
childIds: NodeId[] | null; // null = not loaded yet
version: number; // for conflict checks on edits
};
type TreeStore = {
rootId: NodeId;
nodes: Map<NodeId, TreeNode>;
};
From the map to the rows on screen
The screen needs a flat array of visible rows: each with its node id, its depth (for indentation and aria-level), its position among its siblings and how many siblings there are (for aria-posinset and aria-setsize). We compute it with a depth-first walk that only enters folders in the expanded set.
type Row = { id: NodeId; depth: number; pos: number; size: number; kind: "node" | "loading" | "error" };
function visibleRows(store: TreeStore, expanded: Set<NodeId>): Row[] {
const rows: Row[] = [];
const walk = (parentId: NodeId, depth: number) => {
const ids = store.nodes.get(parentId)?.childIds;
if (!ids) {
rows.push({ id: `${parentId}:loading`, depth, pos: 1, size: 1, kind: "loading" });
return;
}
ids.forEach((id, i) => {
rows.push({ id, depth, pos: i + 1, size: ids.length, kind: "node" });
if (expanded.has(id)) walk(id, depth + 1);
});
};
walk(store.rootId, 1);
return rows;
}
aria-level, aria-posinset and aria-setsize need.Worked numbers: with src (120 children) and src/lib (40 children) open, the walk produces about 165 rows and visits about 165 nodes. The other 199,800 nodes are never touched. Even with node_modules open at 10,000 children, the walk is a tight loop over 10,000 entries: well under 5 milliseconds on a mid-range laptop. When that grows, we cache each folder's row block and only rebuild the blocks of folders that changed.
Check your understanding · Exercise 2
A server event says node n-77 moved from folder A to folder B at index 2. With the flat map, which records change? Select all that apply.
Lazy loading: children arrive when a folder opens
Fetching the whole tree up front fails at our size. 200,000 nodes at about 120 bytes of JSON each is 24 MB before compression, for a user who will open maybe 20 folders. So each folder loads its children the first time it opens. That one choice brings four questions: what the user sees while it loads, what happens when it fails, how long loaded children stay valid, and whether we can start before the click.
Decision When do children load?
What we pick: Load on expand, plus prefetch on intent. Pointer hover and keyboard focus are strong signals that an open is coming. A folder listing is a few KB, so a wasted prefetch costs little, and a hit removes the only visible delay in the tree (see the prefetching lesson). For deep reveals we add one server endpoint that returns a path's ancestors and their children in one response.
A loading state per folder
Each folder's load is a small state machine, kept in a map next to the store: idle (never asked), loading, loaded, error. Loading and error are per folder, never global: if src/legacy fails, src/lib keeps working. The visible rows walk turns loading into a single row reading "Loading…" one level deeper, and error into a row with the message and a Retry button.
- Show the loading row only after about 150 milliseconds. Most cached or fast loads finish before that, and a row that flashes in and out is worse than none. The arrow can turn into a small spinner at once.
- Don't block the rest of the tree. The user can keep navigating, open other folders, or collapse the loading one. Collapsing doesn't cancel the request: the result is still useful.
- Deduplicate. Hover prefetch, a click and a keyboard Right arrow can all ask for the same folder. One promise per folder id, shared by every caller (see the request deduplication lesson).
- Errors stay where they happened. The error row says what went wrong ("No permission" is not "Network error") and Retry refetches only that folder. A 403 shouldn't offer Retry at all.
- Counts are unknown until a folder loads. Don't show "12 items" before you have them. If the server sends a
childCounthint, use it only for display, never foraria-setsizeof rows you haven't got.
A folder with 10,000 children
One folder can be huge. We page its children with a cursor (see the pagination lesson): the first page of 500 arrives, the rows render, and a "Load more" row at the end fetches the next page when it scrolls into view. Sorting must be done by the server (folders first, then names in the user's locale), because the client can't sort what it hasn't loaded. While pages are missing, aria-setsize uses the server's total if it sends one, and -1 (meaning unknown, allowed by ARIA 1.2) if it doesn't.
How long loaded children stay valid
Collapsing a folder keeps its children in the store, so reopening is instant. But they can go stale. Two rules keep them fresh: live events from the server patch the store directly (chapter on real-time updates), and if the event stream was disconnected, we mark every loaded folder as stale and refetch each one the next time it opens, showing the cached rows meanwhile. That is stale-while-revalidate at folder level (see the server state libraries lesson).
Check your understanding · Exercise 3
A user rests the pointer on docs for 300 milliseconds, then clicks it, then presses Right arrow by habit. How many requests for docs should go out? Pick one.
Expand and collapse: one set of open folders
In the naive version each folder component owns an isOpen boolean. That breaks as soon as anything outside the folder needs to know or change it: "Collapse all" in the toolbar, "Reveal in explorer" when you open a file from search, restoring open folders after a reload, and the visible rows walk, which must know every open folder to build the list.
So the open folders become one piece of state: a Set of expanded ids. Toggling a folder adds or removes one id, and the walk asks expanded.has(id). "Reveal" adds every ancestor of a node, and "Collapse all" replaces the set with an empty one. Nothing else in the store changes.
Decision Where does expanded state live?
What we pick: A Set of ids, separate from the node data. Open folders are the user's view of the tree, not a fact about the folder. Keeping them apart means a server update to src can't close it, and the same store can drive a sidebar tree and a "Move to" dialog tree with different folders open.
Collapsing a parent keeps the children's state
Collapse src while src/lib and src/lib/utils are open. Only src leaves the set. The walk stops at src, so its children vanish from the screen, but their ids stay in the set. Open src again and you get back exactly what you had.
VS Code's explorer behaves this way, and users rely on it to tidy the view without losing their place. A "Collapse all" command is the deliberate exception: it clears the whole set.
Persisting per user
We save the set to localStorage under a key that includes the user and the tree, like tree:u-42:workspace-7:expanded, as an array of ids, written at most once a second. On load we read it and drop ids that aren't folders once their parents load. For a set of 300 ids at about 20 bytes each that is 6 KB, far below the usual 5 MB localStorage quota. If the user wants open folders to follow them across devices, the same array can go to a user preferences endpoint instead.
Restoring has a catch: an open folder whose parent isn't loaded yet can't show. We restore top-down: load the root, then every saved folder whose parent is now loaded, in parallel, level by level. With 8 levels that is 8 rounds, so the ancestors endpoint from the last chapter is worth adding here too.
Why "Expand all" is dangerous
On a lazy tree, "expand all" means "load every folder": 30,000 requests in our example, and up to 200,000 rows. That is why file explorers make "Collapse all" a toolbar button but rarely offer the opposite. If the product needs it, scope it: expand all children of one folder, one level at a time, with a limit (for example, stop at 2,000 rows and say so). The ARIA tree pattern's * key is already scoped that way: it expands the siblings of the focused node, not the whole tree.
Check your understanding · Exercise 4
The expanded set is {src, src/lib, src/lib/utils}. The user collapses src, then a server event deletes src/lib. What should happen? Select all that apply.
The tree pattern: roles, keys and what a screen reader hears
A nested ul of links is readable, but it is not a tree to assistive technology. A screen reader user hears "list, 120 items" and has to Tab through every row of every open folder. A tree with the right roles is announced as one widget: one Tab stop, arrow keys inside, and each row read with its level and position. The WAI-ARIA Authoring Practices Guide (APG) defines this tree view pattern; the aria-patterns lesson covers roles in general, so here we apply it.
Roles and states
| Attribute | Where | What it tells assistive technology |
|---|---|---|
role="tree" and aria-label | The container | This is a tree called "Files". |
aria-multiselectable="true" | The container, when multi-select is on | More than one node can be selected. |
role="treeitem" | Each node | This is one node. |
aria-expanded | Folders only, never files | true open, false closed. Missing means it can't expand. |
role="group" | The element wrapping a folder's children | These nodes are children of that folder (in the nested DOM version). |
aria-level, aria-setsize, aria-posinset | Each node | Level 3, item 2 of 5. Required when the DOM doesn't show the structure, as in a virtualized flat list. |
aria-selected | Each selectable node | Selected or not. In multi-select, set it to false on unselected nodes so they're announced as selectable. |
Our tree is virtualized (a later chapter), so rows are a flat list of siblings in the DOM with no group nesting. Then the browser can't work out levels and positions, and we must give aria-level, aria-setsize and aria-posinset on every row. The visible rows walk already computes depth, pos and size, so this is free.
One Tab stop: roving tabindex or aria-activedescendant
Decision How does keyboard focus move between rows?
What we pick: Roving tabindex for the tree itself, with one rule for virtualization: the focused row is always rendered, even when scrolled out of the window (see the virtualization chapter). It gives real focus with the widest support. We switch to aria-activedescendant only in the filter mode where typing stays in the search box, the same choice the command palette guide makes (see also the focus management lesson).
The keyboard model
| Key | On a closed folder | On an open folder | On a file |
|---|---|---|---|
| Down / Up | Next / previous visible row | Next / previous visible row | Next / previous visible row |
| Right | Open it, focus stays | Move to its first child | Nothing |
| Left | Move to its parent | Close it, focus stays | Move to its parent |
| Home / End | First / last visible row | First / last visible row | First / last visible row |
| Enter | Activate (open the folder's view, or toggle) | Activate | Open the file |
| Type letters | Jump to the next visible row whose name starts with them | Same | Same |
* | Expand every sibling folder at this level | Same | Same |
Note how Right and Left are symmetric: Right goes in (open, then first child) and Left goes out (close, then parent). A user can climb out of a deep folder with Left Left Left without knowing the structure. When Tab brings focus into the tree, APG says it lands on the selected node (the first selected one in multi-select), or on the first node if nothing is selected.
Typeahead means typing letters to jump to a row. APG recommends it for every tree but sets no timing, so we pick one: letters typed within about 500 milliseconds of each other build a prefix ("pa" finds package.json, not public). The search starts after the focused row and wraps around. It only searches visible rows; it never loads or opens folders (the keyboard navigation lesson covers the general pattern).
function onKeyDown(e: KeyboardEvent, row: Row) {
const node = store.nodes.get(row.id)!;
const open = expanded.has(node.id);
switch (e.key) {
case "ArrowDown": return focusRow(rowIndex(row) + 1);
case "ArrowUp": return focusRow(rowIndex(row) - 1);
case "ArrowRight":
if (node.kind !== "folder") return;
return open ? focusFirstChild(node) : expand(node.id);
case "ArrowLeft":
if (node.kind === "folder" && open) return collapse(node.id);
return node.parentId !== store.rootId && focusNode(node.parentId!);
case "Home": return focusRow(0);
case "End": return focusRow(rows.length - 1);
case "*": return expandSiblings(node);
default:
if (e.key.length === 1 && !e.ctrlKey && !e.metaKey) typeahead(e.key);
}
}
What a screen reader says, with a hedge
With these attributes, NVDA or JAWS on Windows reads a row roughly as "lib, collapsed, level 2, 3 of 7", and VoiceOver on macOS reads similar information in a different order and wording. Treat that as a goal, not a promise: each pairing of screen reader and browser words it differently, some skip the level when it hasn't changed, and older versions had bugs with trees. Test with at least NVDA with Firefox or Chrome, and VoiceOver with Safari.
When a folder finishes loading, don't announce every new child; the user will hear them when they arrive with the arrow keys. A load error, though, should be announced once through a polite live region, because the user can't see the error row unless they move to it.
Check your understanding · Exercise 5
Focus is on the file src/lib/format.ts. The user presses Left, then Left again. What happens? Select all that apply.
Selection: one node, or many in tree order
Focus and selection are different things: focus is the one row the keyboard is on, and selection is the set of nodes an action will apply to: move, delete, download. In single-select mode they often travel together (arrow keys move both). In multi-select mode they separate: you can arrow down past five selected files without changing the selection.
We use the same model as the Google Drive guide so the sidebar tree and the file grid feel like one product: a Set of selected ids plus an anchor, the node where the last plain click or Ctrl-click happened. Shift ranges are measured from the anchor. One key differs. In Drive's grid, Shift+Arrow extends a range from the anchor; in a tree, both APG selection models say Shift+Down and Shift+Up move focus and toggle the row they reach, so we follow the tree pattern here.
| Input | Selection after | Anchor after |
|---|---|---|
| Click | Just that node | That node |
| Ctrl+click (Cmd+click on Mac) | Toggle that node, keep the rest | That node |
| Shift+click | Every visible row from anchor to here, replacing the last range | Unchanged |
| Ctrl+Shift+click | Add the range to the existing selection | Unchanged |
| Space (multi-select) | Toggle the focused node | Focused node |
| Shift+Down / Shift+Up | Move focus and toggle that node, which grows the range as you go | Unchanged |
| Shift+Space | Every visible row from the anchor (the most recently selected node) to the focused node | Unchanged |
| Ctrl+A | Every visible row. APG says all nodes; on a lazy tree we keep it to rows the user can see | Unchanged |
| Escape | Clear | None |
What a range means in a tree
In a list, "from row 3 to row 9" is obvious. In a tree, a range is the run of visible rows between the anchor and the target, in the order the walk produced them. That means a range can cross levels: from src/app/page.tsx down to tests/setup.ts takes in the rest of app, the closed folders lib and scripts as one row each, and the start of tests. Rows inside closed folders are not selected, because the user can't see them.
Because we already have the visible rows array, a range is two index lookups and a slice. Store the row index for each id in a Map built alongside the rows, so Shift+click on row 40,000 doesn't scan the array.
Edge cases that decide the details
- Collapsing a folder that holds selected nodes. We keep them selected but hidden, and the status bar says "5 selected, 3 hidden". A delete must confirm hidden items by name, or a user can delete files they forgot about. Some products deselect hidden items instead; either is fine if it's consistent and clear.
- Selecting a folder and one of its children. For a move, moving the folder already moves the child. We reduce the selection to its top-most nodes before any action, so the child isn't moved twice.
- Selected nodes deleted by someone else. Remove them from the set when the event arrives. If the anchor was deleted, the next Shift-click acts like a plain click.
- Screen readers. Every row in a multi-select tree gets
aria-selectedastrueorfalse. APG also allowsaria-checkedinstead, and suggests it for multi-select when nothing else decides, for example when rows show checkboxes. Pick one per tree, never both, and use the same convention across the product.
Check your understanding · Exercise 6
Visible rows are: src, src/app, src/app/page.tsx, src/lib (closed), README.md. The user clicks src/app/page.tsx, then Shift-clicks README.md. Which nodes are selected? Select all that apply.
Virtualization and filtering: a tree is a list once it is flattened
Open node_modules in a nested render and the browser builds 10,000 rows of a few elements each: around 40,000 DOM nodes, with layout and style for all of them. On a mid-range laptop that is a long task of several hundred milliseconds, and every later expand above it shifts all those rows again. The user sees maybe 40 rows at a time.
The visible rows walk solves most of this already. It turns the tree into a flat array, and a flat array of fixed-height rows is exactly what a virtualized list renders well: only the rows in the viewport plus a small buffer exist in the DOM (see the virtualization lesson and the virtualized list guide). With 22 pixel rows in an 880 pixel panel, that is 40 rows plus 10 above and below: 60 rows whatever the tree size.
Decision How do we render a huge tree?
What we pick: Flatten, then virtualize. It is what large production trees do: VS Code's explorer is a virtualized list of rows with indentation, not nested elements, and react-arborist renders its rows the same way. The extra work (ARIA position attributes, a filter box, focus rules) is work we had to do anyway for lazy loading and keyboard support. For small trees under about 500 visible rows, the same component can skip the windowing and render all rows, with no change to its API.
body.Keeping focus on a row that scrolls away
With roving tabindex, DOM focus sits on the focused row's element. If the user scrolls the panel with the mouse wheel, that row leaves the window and a naive virtualizer removes it. Focus then falls to document.body, the next arrow key does nothing, and a screen reader may announce nothing at all or jump to the top of the page.
- Always render the focused row. The virtualizer takes an extra index to keep: the focused row is rendered at its real position even when outside the window, just off screen. It costs one extra row.
- Scroll it back on key press. When the user presses an arrow key and the next row is outside the window, update the scroll position first so the row renders, then focus it, and call
scrollIntoView({ block: "nearest" })so the panel moves the least amount. - Keep focus by id, not index. Store the focused node id. If an expand above inserts 400 rows, the focused row's index changes but its id doesn't, so focus stays on the same node.
- Reveal on demand. "Reveal in explorer" adds ancestors to the expanded set, waits for any loads, recomputes rows, then scrolls the row to the center and focuses it.
Check your understanding · Exercise 7
The tree is virtualized. Focus is on row 120. The user scrolls with the wheel to row 2,000 and presses Down. What should happen? Pick one.
Filtering: matches with their ancestors
Because the tree is virtualized, the browser's find-in-page can't see most rows, so the tree needs its own filter. Typing "button" should not show a flat list of 30 files named Button.tsx with no context. It should show each match in place: with every ancestor folder visible and open, and nothing else. A folder with no match below it is hidden.
Decision Where does filtering happen?
What we pick: Both, in layers. Filter loaded nodes on the client at once, then merge server results as they arrive, marking the list as "searching…" until they do. Small trees that are fully loaded never need the server. The debounce and request race rules come from the debounce and request races lessons.
The filter works on a copy of the view, not the data. We build a separate filter expanded set: every ancestor of a match. The visible rows walk uses it instead of the user's set and skips any child not marked. When the filter clears, the user's own expanded set comes back untouched, so filtering never rearranges their tree.
Matching letters are highlighted in each row, and a live region says "23 matches" after the results settle.
Check your understanding · Exercise 8
The user filters for "util" and gets three matches in different folders. Then they clear the filter. Which statements are right? Select all that apply.
Editing: drag to move, and rename in place
Dragging in a tree has one problem a list doesn't: the same pointer position can mean three things. Over the row lib, the user might want to drop before lib, after lib, or inside lib. The pointer mechanics (pointer events, a drag threshold of a few pixels, auto-scroll near the edges, a drag preview) are the same as in the kanban board guide, so here we focus on what is special to trees: choosing the drop target, the rules that block bad drops, and the move itself.
Which drop does the pointer mean?
We split each row into zones by the pointer's vertical position. For a folder: the top quarter means before, the middle half means inside, the bottom quarter means after. For a file, which can't hold children: top half before, bottom half after.
The middle zone is larger because dropping into a folder is the most common move in a file tree. Atlassian's Pragmatic drag and drop uses the same quarter, half, quarter split for list and tree items.
An open folder needs one exception. Its bottom edge touches its own first child, so a line drawn there reads as "first child of this folder", not "after the folder and all its children". So on an open folder with visible children, the bottom quarter means inside, at index 0; Atlassian's notes for trees give the same rule (no "after" on expanded items).
The bottom edge of the last child in a folder is ambiguous. Below lib/format.ts (the last child of lib), "after" could mean after format.ts inside lib, or after lib itself one level up.
The idea the Notion guide uses for nesting blocks helps here: the horizontal position picks the depth. If the pointer is left of the row's indentation for its level, the drop goes to the shallower level. We draw the drop line starting at the chosen depth's indent, so the user sees where it will land before letting go.
Drops that must be blocked
- A folder into itself or its own descendant. Dropping
srcinsidesrc/libwould makesrcits own grandparent: a cycle, and the subtree would vanish from the root. Check it by walking upparentIdfrom the target to the root; if you meet any dragged id, block the drop. That walk is as long as the depth, usually under 20 steps. - Into a folder the user can't write to. Use a
canDropcallback with the node's permissions, and show the not-allowed cursor. - A name collision. Moving
index.tsinto a folder that already hasindex.ts. Let the drop happen but ask: replace, keep both (asindex (1).ts), or cancel. Products differ here; the tree only reports the collision. - A no-op. Dropping a node right where it is. Don't send a request.
While dragging over a closed folder for about 700 milliseconds, we open it (and load it if needed) so the user can drop deeper. If the drag leaves without a drop, we close the folders we opened, so a drag across the tree doesn't leave ten folders open.
The move: optimistic, with rollback
Decision When does the tree show the moved node in its new place?
What we pick: Optimistic with rollback (see the optimistic updates lesson). Moves fail rarely (permissions, a name collision, someone deleted the target), and when they do, the node going back with a clear message is easy to understand. The request includes the node's version, so the server can reject a move based on stale data.
async function moveNode(id: NodeId, to: NodeId, index: number) {
const node = store.get(id);
const from = { parentId: node.parentId!, index: store.indexInParent(id) };
store.move(id, to, index); // optimistic
expanded.add(to); // show where it went
focusNode(id);
try {
const res = await api.move({ id, to, index, version: node.version });
store.patch(id, { version: res.version });
} catch (err) {
store.move(id, from.parentId, from.index); // rollback
focusNode(id);
announce(`Couldn't move ${node.name}: ${reason(err)}`);
}
}
The keyboard and touch alternative: "Move to…"
Drag and drop is not reachable for keyboard and many screen reader users, and is awkward on touch. Every move must also be possible without dragging. We add a "Move to…" command (in the context menu, and on a shortcut) that opens a dialog with a second, smaller tree of folders only. The user picks a folder with the same arrow keys and presses Enter.
This is the same tree component with a filter for folders, a good test that our API is reusable. Cut and paste (Ctrl+X, then Ctrl+V on a folder) is a second keyboard path that file manager users already know. The kanban board guide's keyboard "pick up and move" mode is a third option, better for reordering within one folder.
Check your understanding · Exercise 9
The user drags src and docs together and drops them inside src/components. What should the tree do? Pick one.
Renaming in place
Renaming in a dialog breaks the flow. File managers rename in place: in Windows File Explorer and in VS Code on Windows and Linux you press F2, while macOS Finder and VS Code on macOS use Return. The name then turns into a text field.
We follow that: F2, the context menu "Rename", or a slow second click on a selected name swaps the row's label for an input. Our Enter stays "activate", as the APG keys say; a product that wants Finder's habits can map Return to rename on macOS instead.
- What's selected. For files, select the name without the extension (
formatofformat.ts), so typing replaces the part people usually change. For folders, select everything. - Keys. Enter saves, Escape cancels, and blur (clicking elsewhere) saves, like VS Code. While the input has focus, the tree's own key handler must ignore keys, or typing a letter would trigger typeahead and arrows would move rows.
- Validation while typing. Empty names, names with
/, and a name that matches a sibling's (case-insensitive if the backend is) show an inline error under the input and block Enter. The sibling check is cheap: the parent'schildIdsare loaded, because you can only rename what you can see. - Save. Optimistic, like a move: show the new name at once and roll back with a message if the server says no. The server is the final check, because a sibling might have been created a second ago by someone else.
- Focus after. Return focus to the row, not the body. With virtualization, the row must stay rendered while the input is open, for the same reason as the focused row.
- Sort order. If siblings are sorted by name, a rename can move the row. Move it after saving, then scroll it into view, so the user sees where it went.
Live updates from other people without losing your place
In a shared team folder, others create, rename, move and delete nodes while you browse. The naive fix is to refetch every open folder every 30 seconds and replace its children. That costs a request per open folder per tick, shows changes up to 30 seconds late, and replacing arrays makes rows jump under the pointer.
Instead the server pushes small events for the tree the user has open, over the socket or Server-Sent Events channel the app already has (see the choosing realtime lesson). Each event names a node and what changed. Because the store is normalized, each event is a few record updates.
| Event | Store change | If the parent isn't loaded |
|---|---|---|
created { node, parentId, index } | Add the node; insert its id in the parent's childIds | Ignore it, except set the parent's hasChildren to true so the arrow appears |
renamed { id, name, version } | Patch the name; re-sort siblings if sorted by name | Ignore if the node isn't in the store |
moved { id, from, to, index, version } | Remove from old parent, insert in new, update parentId | If the new parent isn't loaded, drop the node from the store |
deleted { id } | Remove the node and its loaded subtree; remove from parent | Ignore |
Keeping your place
- Scroll anchoring. If 5 rows are inserted above the viewport, the content you're looking at moves down by 5 rows. Before applying the change, note the first visible row's id and its offset from the top; after recomputing rows, set the scroll so that row is back at the same offset. Some browsers do this for normal page flow with CSS scroll anchoring, but support isn't universal, and a virtualized list positions rows itself anyway, so it must do this by hand.
- Focus by id. Focus stores a node id, so inserts and moves elsewhere don't move it. If the focused node is deleted, move focus to the next sibling, else the previous one, else the parent, and announce "format.ts was deleted" politely.
- Don't move what the user is holding. If a remote move arrives for a node being dragged or renamed, apply it to the store but keep the drag or rename session; when the user finishes, their action is checked against the new
versionand may be rejected with a clear message. - Batch bursts. A teammate moving 500 files sends 500 events. Collect events for one animation frame, apply them together, recompute rows once.
scrollTop so that row sits where it was. Focus is stored by id, so it follows utils.ts to its new index.Your own changes coming back
When you move a node, the server also broadcasts that move to everyone, including you. Applying your own echo again is harmless if the event handler is idempotent (the node is already where the event says), but it can briefly undo a newer optimistic change. We tag each request with a client mutation id and skip events carrying our own id. Versions decide the rest: an event with a version lower than the node's current one is old news and is ignored.
Check your understanding · Exercise 10
Keyboard focus is on utils.ts at row 300, the user is reading rows 280 to 320, and a teammate creates 12 files in a folder shown at row 50. What should the user notice? Select all that apply.
Architecture and interfaces: a headless core, a thin view, and a data source
With the decisions made, we can place them. The goal is a component that two very different products can use: a code editor with files from a git service, and a document app with folders from a REST API and live events. So the tree must not know where data comes from, and the parts that are hard to get right (keys, ARIA, selection, rows) must not be rewritten by every team.
| Part | Responsibility | Why the boundary is here |
|---|---|---|
| Data source adapter | getChildren, move, rename, search, and a subscription for live events. Written by each product. | The only part that knows the API. Swapping REST for GraphQL touches nothing else. |
| Node store | The normalized map, per-folder load state, request dedupe, applying events. | Server data, shared by every view of the same tree (sidebar and "Move to" dialog). |
| View state | Expanded set, selection set and anchor, focused id, filter, rename session. | Per tree instance and per user. Can be controlled by the parent. |
| Rows selector | Walks store plus view state into the rows array with depth, pos and size; memoized. | One pure function that keyboard, selection, virtualizer and ARIA all read. |
| Interaction controller | Key handler, typeahead, click and Shift-click, drag zones, drop rules. | Behaviour lives in one place, tested without a DOM. |
| Virtualized view | Renders the window of rows, ARIA attributes, focus ring, loading and error rows. | The only part that touches the DOM. Products pass a renderNode for the row content. |
| Persistence | Saves expanded ids to localStorage or a preferences endpoint. | Optional, so tests and the dialog tree can skip it. |
The split between node store and view state is the one to defend in the interview. Server data is one truth shared by all views. View state differs per tree, per user, per moment. Mixing them is how a server refresh closes your folders, or how opening a folder in the dialog opens it in the sidebar too.
How data moves
Reads go one way: the server returns a folder's children, the adapter normalizes them, the store merges them, the rows selector recomputes, and the view renders only the rows in the window. Writes go the other way: a drop or a rename calls the controller, which updates the store optimistically and calls the adapter; the server answers and broadcasts an event, which comes back in through the same store merge.
- One write path for everything. Initial loads, events, optimistic edits and rollbacks all end in the store's
merge,moveandremove. There is no second way to change a node, so there's one place to debug. - Recompute rows once per frame. Several changes in one frame (an event burst, an expand plus a load) produce one rows array and one render.
- The view never fetches. It asks the controller to expand; the controller asks the store; the store asks the adapter. Rows can be unmounted and remounted by the virtualizer at any time without starting requests.
Check your understanding · Exercise 11
The document app shows the same team folder in the sidebar tree and in a "Move to" dialog tree at once. Which should the two trees share? Select all that apply.
The contracts: state lifetimes, the server API and the component API
With the parts placed, three contracts remain: how long each piece of state lives, what the server returns and accepts, and what a product team passes to the component. Interviewers often ask for exactly these shapes, so have them ready to write.
How long each piece of state lives
| State | Lives in | Lifetime |
|---|---|---|
Nodes, childIds, versions | Node store (in memory, shared) | Session; refreshed by events and stale checks |
| Load state per folder, in-flight promises | Node store | Until loaded or retried |
| Expanded ids | Tree instance, saved to localStorage | Across reloads, per user and tree |
| Selected ids, anchor, focused id | Tree instance, or the parent if controlled | Until navigation; selection could go in the URL for shareable links |
| Filter query | Tree instance | Until cleared |
| Rename session, drag session | Interaction controller | One gesture |
| Pending optimistic edits with undo records | Node store | Until the server answers |
The server API
Two reads and one write cover the tree. Children come sorted and paged by the server, because the client can't sort what it hasn't loaded. Every node carries a version, and every edit sends it back, so the server can refuse an edit made on stale data with a 409 instead of silently overwriting someone else's change.
GET /trees/ws-7/nodes/f-12/children?cursor=&limit=500
200 OK
{
"items": [
{ "id": "f-40", "name": "lib", "kind": "folder",
"hasChildren": true, "version": 31 },
{ "id": "n-77", "name": "format.ts", "kind": "file",
"hasChildren": false, "version": 8 }
],
"total": 2,
"nextCursor": null
}
GET /trees/ws-7/nodes/n-9124/path
200 OK { "ancestors": ["root", "f-12", "f-40"],
"children": { "root": [...], "f-12": [...], "f-40": [...] } }
POST /trees/ws-7/moves
{ "mutationId": "c-5f2", "items": [
{ "id": "n-77", "to": "f-55", "index": 2, "version": 8 } ] }
200 OK { "items": [{ "id": "n-77", "version": 9 }] }
409 Conflict { "error": "version_mismatch", "id": "n-77", "current": 10 }
422 Unprocessable { "error": "name_taken", "id": "n-77" }
403 Forbidden { "error": "no_write_access", "id": "f-55" }
event: moved
data: { "id": "n-77", "from": "f-40", "to": "f-55", "index": 2,
"version": 9, "mutationId": "c-5f2" }
The component API
Decision What does the design system ship?
What we pick: Headless hook plus a default component. The hard parts (keys, ARIA, selection, virtualization, drop rules) live in useTree, and Tree covers 90 percent of uses with a renderNode slot. See the component API design lesson for the controlled and uncontrolled pattern used below.
type TreeProps = {
rootId: NodeId;
getChildren: (id: NodeId, cursor?: string, signal?: AbortSignal)
=> Promise<{ items: TreeNode[]; nextCursor?: string; total?: number }>;
renderNode?: (node: TreeNode, state: RowState) => React.ReactNode;
selectionMode?: "none" | "single" | "multiple";
expanded?: ReadonlySet<NodeId>; // controlled when given
defaultExpanded?: ReadonlySet<NodeId>;
onExpandedChange?: (next: ReadonlySet<NodeId>) => void;
selected?: ReadonlySet<NodeId>;
onSelectedChange?: (next: ReadonlySet<NodeId>) => void;
canDrop?: (drag: NodeId[], target: NodeId, where: "before" | "after" | "inside") => boolean;
onMove?: (drag: NodeId[], target: NodeId, index: number) => Promise<void>;
onRename?: (id: NodeId, name: string) => Promise<void>;
onActivate?: (id: NodeId) => void;
"aria-label": string;
};
Tree props. onMove and onRename return promises: rejecting one triggers the rollback.getChildrenis async and takes anAbortSignal, so the tree can cancel a page load when the folder is removed or the tree unmounts.- Expanded and selected are controllable. The editor controls
expandedto implement "reveal active file"; the document app leaves it uncontrolled withdefaultExpandedfrom storage. renderNodegets state, not handlers. It receivesisSelected,isFocused,isLoading,depth, and returns content only. The row element, its ARIA and its events stay inside the component, so a custom row can't break keyboard support.aria-labelis required by the type, because a tree without a name is announced as just "tree".
Open source trees made similar splits, which makes them useful to study. React Aria's Tree handles keys, ARIA and selection and leaves styling to you, with drag and drop and a load-more item for async data. react-complex-tree separates a data provider from the tree environment, lets several trees share one state, and has F2 rename, typeahead and multi-select drag built in. react-arborist virtualizes its rows and supports drag to move, inline rename and filtering.
Check your understanding · Exercise 12
A product team wants a git status badge ("M", "U") on each row. With our API, what should they do? Select all that apply.
Running it in the interview
In 45 minutes you can't cover every chapter in depth. Pick the order that shows judgement: the data shape early, because everything rests on it, then two or three deep dives chosen by what the interviewer reacts to. Say the trade-off out loud each time you choose.
| Minutes | What to do | What to draw |
|---|---|---|
| 0 to 5 | Ask the scope questions: size, lazy or full, multi-select, editing, live updates, who builds rows. | Nothing yet. Write the numbers down. |
| 5 to 12 | Flat normalized store, childIds null until loaded, expanded set, the visible rows walk. | The flat map and the rows it produces. |
| 12 to 20 | Lazy loading with a per-folder state machine, dedupe, prefetch on intent, paging big folders. | The state machine. |
| 20 to 28 | Tree pattern: roles, roving tabindex, the arrow key model, level and position attributes. | A small table of keys. |
| 28 to 38 | One or two deep dives: virtualization with focus, or drag to move with drop zones, cycle check and rollback. | Drop zones on one row; the move sequence. |
| 38 to 45 | Architecture split (store, view state, controller, view), the component API, and follow-ups. | The architecture boxes. |
What to say early, in one breath: "I'll store nodes flat by id with parentId and childIds, keep open folders in a separate set, and derive the visible rows by walking only open folders. That gives me one array that keyboard, selection, virtualization and ARIA all read." That sentence shows the interviewer you see the core idea, and every later answer refers back to it.
Follow-ups to expect
- "The user opens a file from search that is 7 levels deep. How do you reveal it?" Call the path endpoint for its ancestors and their children in one request, merge them, add the ancestors to the expanded set, recompute rows, scroll the row to the center, focus it.
- "Why not
aria-activedescendant?" Roving tabindex gives real focus and the widest support; we keep the focused row rendered so virtualization doesn't break it. We do usearia-activedescendantin filter mode, where focus must stay in the search box. - "Two users move the same folder at once." Each move carries the node's version. The second gets 409, rolls back, and the store already holds the first move from the event. Tell the user "lib was moved by Priya".
- "How does this work right to left?" Indentation comes from the inline start, so use logical CSS properties like
padding-inline-start. In RTL the tree is mirrored, so the arrow key that points toward the children (Left) should open folders and the other should close them. Check what the platform's own trees do and test with people who read RTL. - "Can the server send the whole tree for small projects?" Yes: the adapter can resolve
getChildrenfrom memory. The store and view don't change. That's the point of the adapter boundary. - "VS Code shows
src/main/javaas one row. How would you do that?" That is VS Code's compact folders setting, on by default: a chain of folders that each hold exactly one folder is drawn as one row. Do it in the rows walk: merge such a chain into one row whose label lists every name. Keyboard and ARIA treat the chain as one row, while a click on one name can still target that folder. - "What do you measure?" Time from expand click to children painted (p75 under 300 milliseconds), long tasks during expand, move failure rate by error code, and how often users hit Retry.
Check your understanding · Exercise 13
The interviewer says: "We're out of time for details. What is the single most important decision in your design?" Which answers are strong? Select all that apply.
Summary
- Store the tree flat: a map of nodes by id, each with
parentId, and folders with orderedchildIdsthat staynulluntil loaded. Lookups, moves and lazy loads become a few record updates. - Keep open folders in a Set of ids, separate from node data and persisted per user. Collapsing a parent keeps its children's state; "expand all" on a lazy tree means loading everything, so scope it.
- Derive one array of visible rows by walking only open folders. Keyboard order, Shift ranges, virtualization and
aria-level,aria-setsize,aria-posinsetall read it. - Load children on expand with a per-folder state machine (idle, loading, loaded, error), one shared request per folder, prefetch on hover or focus, and paging for huge folders.
- Follow the APG tree pattern: one Tab stop with roving tabindex, Right goes in and Left goes out, Home and End, typeahead and
*. Keep the focused row rendered when virtualized. - Drag to move needs drop zones (before, inside, after, with horizontal position for depth), a walk up from the target to block cycles, optimistic moves with versioned rollback, and a keyboard "Move to" command.
- Apply live events to the normalized store in batches, keep focus by id and anchor the scroll to the first visible row, so other people's edits never move the user's place.
- Ship a headless
useTreeplus a defaultTreewith an asyncgetChildren, arenderNodeslot, controlled expanded and selected sets, and promise-returningonMoveandonRename.
Check your answer
Tick what your own design covered. Pick the level you're interviewing for: each level includes the ones before it.
References
- WAI-ARIA Authoring Practices Guide: Tree View Pattern Roles, states and the full keyboard model, including multi-select keys.
- WAI-ARIA APG: File Directory Treeview Example A working tree with computed properties to test with a screen reader.
- WAI-ARIA APG: Developing a Keyboard Interface Roving tabindex versus aria-activedescendant, explained.
- WAI-ARIA 1.2: aria-setsize Including the value -1 when the total is unknown.
- MDN: ARIA tree role Attributes and keyboard interactions with examples.
- web.dev: Virtualize large lists with react-window Why rendering only the visible rows keeps big lists fast.
- MDN: Element.scrollIntoView() The block nearest option used to keep the focused row visible.
- Redux docs: Normalizing State Shape The flat, id-keyed store this design uses, explained with examples.
- Atlassian Pragmatic drag and drop: Hitbox Quarter, half, quarter drop zones for list and tree items, and no "after" on expanded tree items.
- React Aria: Tree An accessible tree with selection, drag and drop and async loading, built as hooks and components.
- React Complex Tree A headless tree with a data provider, shared state across trees, F2 rename and multi-select drag.
- react-arborist on GitHub A virtualized React tree with drag to move, inline rename and filtering.
Concepts behind it
- Normalizing stateStoring each entity once, in a table keyed by its id, and having lists and relationships point at ids instead of holding copies, so one change updates every view that shows it.
- ARIA widget patternsThe tested recipes for custom widgets from the ARIA Authoring Practices Guide: combobox, listbox, menu, tabs, dialog, tree, grid, disclosure, and accordion, with the roles, states, and keys each one promises, and how to pick the right one.
- Keyboard navigationMaking every action work without a mouse: a Tab order that follows the page, a focus ring you can always see, arrow keys inside composite widgets, skip links past repeated content, and no place where focus gets stuck.
- Focus managementDeciding where keyboard focus goes when the page changes under the user (route changes, modals, deletes, new content) so keyboard and screen reader users never lose their place.
- List virtualizationRendering only the rows the user can see in a long list, so a list of 100,000 items costs about as much as a list of 30.
- Optimistic updatesShowing the result of a user's action at once, before the server confirms it, and quietly undoing it if the server says no: the trick that makes likes, checkboxes, and chat messages feel instant on any network.
- Designing component APIsHow to shape a component's props, children, slots, events, and ref so other engineers can use it for cases you didn't predict, without it growing a hundred props: composition, compound components, controlled state, and headless behavior.