cf.completefrontend
My progress
SYSTEM DESIGN · #23

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

Each answer drives a decision later in this guide.
QuestionAnswer we assumeWhat it changes
How big can a tree get?Up to 200,000 files in 30,000 folders; one folder can hold 10,000 childrenWe 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 timeEach folder has its own loading and error state. Counts are unknown until a folder loads.
Who uses it?Mouse, touch, keyboard and screen reader usersWe follow the WAI-ARIA tree pattern with the full keyboard model.
Single or multi-select?Both, chosen by a propCtrl and Shift selection in tree order, and aria-multiselectable.
Can users change the tree?Yes: move by drag or keyboard, rename inline, create and deleteOptimistic moves with rollback, and rules that block impossible moves.
Do others edit it at the same time?Yes, a shared team folderLive insert, move and delete events that must not move the user's focus or scroll.
Who builds the UI on top?Several product teamsA 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.
Wireframe of the explorer panel. At the top, a toolbar with a Filter files box, a New button and a Collapse button. Below, nine rows indented by depth: src open, app open, page.tsx selected, lib closed with keyboard focus, legacy open with an error row reading Couldn't load, Retry, docs open with a Loading row, and README.md. Notes name the toolbar, the selected row with aria-selected, the focused row with tabindex 0, the error row with Retry, and the loading row.Explorer: workspaceFilter filesNewCollapse▾ src▾ apppage.tsx▸ lib▾ legacyCouldn't load. Retry▾ docsLoading…README.mdfilter, collapse allselected rowfocus: tabindex 0error row, Retryloading row
Every row is one line of a flat list, indented by its depth. Focus (dashed outline) and selection (shaded) are different rows here on purpose: they are separate state. Loading and errors are rows inside the folder they belong to.

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.tsx is 6 levels down, while node_modules next 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.

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-9124 was 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 utils from src/lib to src/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/lib arrive. You must walk down to src/lib and replace its children array, again copying the path.

Decision How should the client store the tree?

Nested objects

Each node has a children array of node objects.

The component recurses over it.

Pros

  • Matches the picture and many server responses.
  • Simple for small, static trees such as a settings menu.

Cons

  • Finding a node by id is a search.
  • Moves and loads copy whole paths.
  • A node can't be looked up without knowing its path.

Flat map with parentId and childIds

One map from id to node. Each node stores its parentId and, for folders, an ordered childIds array.

The root is one known id.

Pros

  • Find, update and delete are one lookup.
  • A move edits three records: the node and two parents.
  • Loaded children are just more entries plus one childIds array.

Cons

  • The client must keep parentId and childIds in agreement.
  • Rendering needs a separate step that turns the map into rows.

Flat list with parentId only

An array of rows, each with a parentId, like a database table.

Children are found by filtering.

Pros

  • Easy to send and store.
  • No arrays to keep in sync.

Cons

  • Finding a folder's children scans every node.
  • Sibling order needs another field and sorting.
  • Fine on the server, slow as a render model.

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.

The same tree stored two ways. On the left, nested: src holds app and lib, and app holds page.tsx; moving lib means copying every ancestor on both paths. On the right, a flat map with one row per node giving its id, parentId and childIds: root has child src; src has parent root and children app and lib; app has parent src and child page; page is a file; lib has parent src and childIds null, meaning not loaded. Moving lib edits three rows.nested objectssrcapppage.tsxlibmove lib: copy each ancestorfind by id: search the treeflat map by ididparentIdchildIdsrootnullsrcsrcrootapp, libappsrcpagepageappfilelibsrcnullmove lib: edit 3 rowsfind by id: one lookup
Flat storage turns a move into three record edits and a lookup into one 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.

ts
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>;
};
The store. Loading and error state live in a separate map, shown in the lazy loading chapter.

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.

ts
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;
}
Cost is proportional to the visible rows, not the tree. A real version also emits an error row and memoizes per folder.
How the visible rows are built. The expanded set holds src and lib. On the left, the stored tree: src with children app, lib and index.ts; app's 40 children are hidden because app is closed; lib's children a.ts and b.ts; then docs, whose 12 children are hidden, and README.md. On the right, the rows array the walk produces, with level and position: src level 1, 1 of 3; app level 2, 1 of 3; lib level 2, 2 of 3; a.ts level 3, 1 of 2; b.ts level 3, 2 of 2; index.ts level 2, 3 of 3; docs level 1, 2 of 3; README.md level 1, 3 of 3.expanded = { src, lib }rows: depth, position of sizesrcsrc: level 1, 1 of 3appapp: level 2, 1 of 340 hiddenliblib: level 2, 2 of 3a.tsa.ts: level 3, 1 of 2b.tsb.ts: level 3, 2 of 2index.tsindex.ts: level 2, 3 of 3docsdocs: level 1, 2 of 312 hiddenREADME.mdREADME.md: level 1, 3 of 38 rows built; hidden children are never visited
The walk only enters folders in the expanded set, so the 52 hidden children are never visited. Each row carries what 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?

Everything up front

One request returns the full tree.

Expand is instant.

Pros

  • Simplest client.
  • Filtering and "expand all" work offline.

Cons

  • Megabytes for big trees, most of it never seen.
  • Slow first paint.
  • Stale the moment anyone else edits.

On expand, one level

Expanding a folder calls getChildren(folderId) if childIds is null.

A loading row shows until it resolves.

Pros

  • Cost follows what the user opens.
  • Errors are local to one folder.

Cons

  • A short wait on first open of each folder.
  • Deep reveals (open a file 6 levels down) need several round trips unless the API helps.

On expand, plus prefetch on intent

Same as on expand, plus a fetch when the pointer rests on a closed folder for about 150 milliseconds, or when keyboard focus lands on it.

Results go into the same cache.

Pros

  • Most opens feel instant.
  • Wasted requests are small: one folder's listing.

Cons

  • Some fetches are never used.
  • Needs a limit so fast mouse sweeps don't fire dozens of requests.

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.

State machine for loading one folder's children. Idle, with childIds null, goes to Loading on expand or hover. Loading goes to Loaded on a 200 response, or to Error when the request fails. Error goes back to Loading when the user presses Retry. Loaded goes back to Loading when the folder is stale and reopened, and its old rows stay on screen during that refetch. A note says a 403 shows no Retry, and there is one promise per folder id.idlechildIds nullloadingrow after 150 msloadedchildIds seterrorrow with Retryexpand or hover200failsRetrystale: refetch, keep rows403: no Retryone promise per id
One machine per folder, so a failure in one folder never blocks another. Loaded folders keep their rows while a stale refetch runs in the background.
  • 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 childCount hint, use it only for display, never for aria-setsize of rows you haven't got.
Sequence diagram of expanding lib for the first time, between User, TreeView, Store and Server. The user presses Right arrow on lib. The TreeView calls expand on the store, which adds lib to the expanded set and returns a loading row. The store sends GET children of lib to the server. The server replies 200 with 40 items, and the store recomputes rows so the TreeView renders 40 rows. In the failure path the server replies 503, and the TreeView shows an error row with Retry, and announces it once.UserTreeViewStoreServerRight on libexpand(lib)loading rowGET lib children200: 40 itemsrender 40 rowsor 503error row, Retry
The loading row appears before the request returns, and the user can keep moving with the arrow keys. On failure only this folder shows an error; focus stays on lib.

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?

A flag on each node record

node.expanded = true in the normalized store.

Pros

  • One place for everything about a node.

Cons

  • Mixes per-user view state with shared server data.
  • A server refresh of a node can wipe the flag.
  • Two trees showing the same data can't differ.

A Set of ids next to the store

One Set of expanded folder ids, owned by the tree instance (or by the parent if controlled).

Pros

  • Separate from server data, so refreshes never touch it.
  • Easy to persist, restore and pass in as a prop.
  • Two trees on the same data keep separate sets.

Cons

  • Can hold ids of nodes that were deleted. Harmless, but clean up on load.

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

The minimum set of attributes for a tree that screen readers can follow.
AttributeWhereWhat it tells assistive technology
role="tree" and aria-labelThe containerThis is a tree called "Files".
aria-multiselectable="true"The container, when multi-select is onMore than one node can be selected.
role="treeitem"Each nodeThis is one node.
aria-expandedFolders only, never filestrue open, false closed. Missing means it can't expand.
role="group"The element wrapping a folder's childrenThese nodes are children of that folder (in the nested DOM version).
aria-level, aria-setsize, aria-posinsetEach nodeLevel 3, item 2 of 5. Required when the DOM doesn't show the structure, as in a virtualized flat list.
aria-selectedEach selectable nodeSelected 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?

Roving tabindex

The focused row has tabindex="0", every other row tabindex="-1".

Arrow keys call .focus() on the next row.

Pros

  • Real DOM focus, so screen readers and :focus-visible work everywhere.
  • The browser scrolls the focused row into view.

Cons

  • The focused row must exist in the DOM. With virtualization, it may have scrolled away.

aria-activedescendant

DOM focus stays on the tree container.

The container's aria-activedescendant names the id of the active row.

Pros

  • Focus never leaves the container, so rows can be recycled freely.
  • Good when focus must stay in an input, as in a filter box over the tree.

Cons

  • The referenced row must still exist in the DOM to be announced.
  • Support has historically been less consistent across screen reader and browser pairs; test it.
  • We must draw the focus ring ourselves.

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

The APG tree keys we implement. Down and Up never open or close anything; only Right, Left and * do.
KeyOn a closed folderOn an open folderOn a file
Down / UpNext / previous visible rowNext / previous visible rowNext / previous visible row
RightOpen it, focus staysMove to its first childNothing
LeftMove to its parentClose it, focus staysMove to its parent
Home / EndFirst / last visible rowFirst / last visible rowFirst / last visible row
EnterActivate (open the folder's view, or toggle)ActivateOpen the file
Type lettersJump to the next visible row whose name starts with themSameSame
*Expand every sibling folder at this levelSameSame

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).

ts
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);
  }
}
The core of the key handler. Right on a folder that is still loading opens it; the first child gets focus only after it loads and the user presses Right again.
Tab into the tree. legacy fails on its first load; lib and docs load in 0.7 seconds.
▾ src
      index.ts
      utils.ts
  package.json
  README.md
Live readout for the focused row. src, expanded, not selected, level 1, 1 of 5. 0 selected. Anchor: none. 9 rows in the tree.aria-level=1aria-setsize=5aria-posinset=1aria-expanded=truearia-selected=falseselected: 0anchor: nonevisible rows: 9a screen reader says, roughly:src, expanded, not selected, level 1, 1 of 5
Click a row or Tab into the tree, then use the arrow keys, Home, End, letters, Space, Shift and *.

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.

Mouse and keyboard selection, following desktop file managers and the APG recommended multi-select keys. The keyboard rows need no modifier held while moving.
InputSelection afterAnchor after
ClickJust that nodeThat node
Ctrl+click (Cmd+click on Mac)Toggle that node, keep the restThat node
Shift+clickEvery visible row from anchor to here, replacing the last rangeUnchanged
Ctrl+Shift+clickAdd the range to the existing selectionUnchanged
Space (multi-select)Toggle the focused nodeFocused node
Shift+Down / Shift+UpMove focus and toggle that node, which grows the range as you goUnchanged
Shift+SpaceEvery visible row from the anchor (the most recently selected node) to the focused nodeUnchanged
Ctrl+AEvery visible row. APG says all nodes; on a lazy tree we keep it to rows the user can seeUnchanged
EscapeClearNone

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.

A Shift-click range in tree order. Nine visible rows: src, app, page.tsx, layout.tsx, lib closed, scripts closed, tests, setup.ts and package.json. page.tsx is the anchor from the last plain click. Shift-click on setup.ts selects every visible row from page.tsx to setup.ts: layout.tsx, lib as one row, scripts as one row, and tests. The 30 files inside closed lib are not selected.srcapppage.tsxlayout.tsx▸ lib▸ scriptstestssetup.tspackage.jsonanchor: last plain clickShift-click: rows 2 to 7a slice of visible rowsclosed folders: one rowfiles in lib: unselected
The range is rows 2 to 7 of the visible rows array: two index lookups and a slice. It crosses levels freely, and closed folders join as single rows.

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-selected as true or false. APG also allows aria-checked instead, 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?

Nested DOM, render everything open

Each folder renders a role="group" with its children inside.

Pros

  • Structure is in the DOM, so ARIA levels come for free.
  • Native find-in-page sees every open row.

Cons

  • DOM size grows with open rows.
  • A big folder freezes the main thread.

Nested DOM with CSS content-visibility

Same DOM, but each folder's group gets content-visibility: auto so off-screen groups skip layout and paint.

Pros

  • Small change, keeps the nested structure.
  • Find-in-page still works.

Cons

  • Still creates every DOM node, so memory and the first render cost remain.
  • Helps paint, not a 10,000-row insert.

Flatten, then virtualize the list

Walk the expanded nodes into a rows array.

Render only the window of rows in view, each absolutely positioned or in a spacer layout, indented by depth.

Pros

  • DOM size is constant.
  • Expanding a 10,000-child folder changes an array, not the DOM.
  • Scroll to any row is index times row height.

Cons

  • We must set aria-level, aria-setsize, aria-posinset ourselves.
  • Find-in-page can't see unrendered rows, so we provide our own filter.
  • Focus needs care when the focused row scrolls away.

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.

Virtualizing the flattened tree. On the left, a tall column stands for 100,000 rows, from row 0 at the top to row 100,000 at the bottom. A small strip near the top is the focused row, kept rendered. A box lower down is the part in view. On the right, the DOM holds a buffer of 10 rows, rows 4,180 to 4,220 that are in view, and another buffer of 10 rows. A note says each row's top is its index times 22 pixels, and the DOM holds about 60 rows.row 0focused, keptin viewrow 100,000buffer: 10 rows4,180 utils/4,181 a.ts4,182 b.ts… 40 rows in viewbuffer: 10 rowstop = index × 22 pxDOM: about 60 rows
The rows array can be 100,000 long; the DOM stays near 60 rows. The focused row is the one exception: it stays rendered off screen so keyboard focus never falls to 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?

Client side, over loaded nodes

Test every loaded node's name; mark matches and their ancestors; walk only marked nodes.

Pros

  • Instant on every keystroke.
  • No server work.

Cons

  • Misses everything in folders never opened, which on a lazy tree is most of it.

Server search, then merge paths

Send the query (debounced about 150 milliseconds). The server returns matching nodes with their ancestor chains.

Merge the chains into the store and show them.

Pros

  • Finds files anywhere, loaded or not.
  • Results are real nodes, so actions on them work.

Cons

  • A round trip per query.
  • Needs a search index on the server.

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.

Filtering for button. On the left, the user's own view: src open, components closed, lib open with a.ts, and docs closed. On the right, the filtered view: src, components and its child forms are open; the matches Button.tsx, SubmitButton.tsx inside forms, and buttons.md inside docs are shown; everything else is hidden. A note says the filter has its own expanded set made of the ancestors of matches, and the user's set is untouched.your view▾ src▸ components▾ liba.ts▸ docsfilter: "button"▾ src▾ componentsButton.tsx▾ formsSubmitButton.tsx▾ docsbuttons.mdfilter set = match ancestorsyour expanded set: untouched3 matches, shown in place
Each match keeps its path, so the user knows which Button is which. Clearing the filter brings back the left picture exactly, because the filter never edits the user's expanded set.

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.

Drop zones. On the left, a folder row lib is split into three zones from top to bottom: before, the top quarter; inside, the middle half; after, the bottom quarter. Below it, a file row a.ts is split in two: before and after. On the right, the rows lib, a.ts and b.ts, where b.ts is the last child of lib. A drop line indented to the children's level means after b.ts inside lib, chosen when the pointer is right of the indent. A drop line at lib's level means after lib, chosen when the pointer is left of the indent.folder row: libbefore (25%)inside (50%)after (25%)file row: a.tsbeforeaftera file has no inside zone▾ liba.tsb.tsx right of indent: in libx left of indent: after libREADME.mdhorizontal position picks the depth
The vertical position picks before, inside or after; on an open folder such as lib, the bottom quarter means first child, because that is where the line appears. Below a folder's last child, the horizontal position picks the depth, and the drop line is drawn at that depth so the user sees the result before letting go.

Drops that must be blocked

  • A folder into itself or its own descendant. Dropping src inside src/lib would make src its own grandparent: a cycle, and the subtree would vanish from the root. Check it by walking up parentId from 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 canDrop callback with the node's permissions, and show the not-allowed cursor.
  • A name collision. Moving index.ts into a folder that already has index.ts. Let the drop happen but ask: replace, keep both (as index (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.

Server
A tree you can drag in: src at level 1, app at level 2, page.tsx at level 3, lib at level 2, format.ts at level 3, shared at level 1, README.md at level 1.▾ src▾ apppage.tsx▾ libformat.ts▾ sharedREADME.mddragging: noneover: nonedrop: none
Drag a row with the mouse, or use Move to below. Try dropping src inside lib.

The move: optimistic, with rollback

Decision When does the tree show the moved node in its new place?

After the server confirms

Send the move, show a spinner on the node, update the store when the response arrives.

Pros

  • Never shows a state the server rejected.

Cons

  • The node snaps back to its old place for 200 to 500 milliseconds, then jumps. Feels broken.

Optimistic, with rollback

Apply the move to the store at once, keep the old parentId and index.

Send the request. On failure, put the node back and say why.

Pros

  • Instant.
  • Failures are rare and clearly reported.

Cons

  • Needs a saved undo record and care if live events touch the same node meanwhile.

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.

Sequence diagram of an optimistic move between User, TreeView, Store and Server. The user drops lib inside shared. The TreeView calls move on the store, which saves an undo record and moves lib at once, so the TreeView renders lib in its new place. The store sends POST moves with version 31. The server replies 200 with version 32. In the failure path the server replies 409 or 403, the store rolls lib back to its old parent and index, and the TreeView announces the reason to the user.UserTreeViewStoreServerdrop lib in sharedmove + undo recordlib in new placePOST /moves v31200: v32or 409 / 403rollback libannounce why
The tree changes before the request returns, so the move feels instant. The undo record (old parent and index) is what makes the rollback exact.
ts
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)}`);
  }
}
One move. Multi-node moves send one batch request and roll back all of them together.

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 (format of format.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's childIds are 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.

Events touch only loaded parts of the tree. Unloaded folders will fetch the current truth when opened.
EventStore changeIf the parent isn't loaded
created { node, parentId, index }Add the node; insert its id in the parent's childIdsIgnore 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 nameIgnore if the node isn't in the store
moved { id, from, to, index, version }Remove from old parent, insert in new, update parentIdIf the new parent isn't loaded, drop the node from the store
deleted { id }Remove the node and its loaded subtree; remove from parentIgnore

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 version and 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.
A live insert above the viewport. Before: the panel shows rows 280 api.ts, 281 auth.ts, then 300 utils.ts with focus, and 301 view.ts, at scrollTop 6,160 pixels. A teammate adds 12 files at row 50. After: the same names are now rows 292, 293, 312 with focus still on utils.ts, and 313, and the tree raised scrollTop by 12 times 22, which is 264 pixels, to 6,424, so the screen looks the same.before280 api.ts281 auth.ts…300 utils.ts301 view.tsafter 12 inserts at row 50292 api.ts293 auth.ts…312 utils.ts313 view.tsscrollTop 6,160 pxfirst row: api.tsscrollTop 6,424 px+12 × 22 px = +264 px
The tree remembers the first visible row's id and offset before applying events, then sets 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.

Seven parts. The first is product code; the rest ship in the design system.
PartResponsibilityWhy the boundary is here
Data source adaptergetChildren, 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 storeThe 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 stateExpanded set, selection set and anchor, focused id, filter, rename session.Per tree instance and per user. Can be controlled by the parent.
Rows selectorWalks 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 controllerKey handler, typeahead, click and Shift-click, drag zones, drop rules.Behaviour lives in one place, tested without a DOM.
Virtualized viewRenders 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.
PersistenceSaves expanded ids to localStorage or a preferences endpoint.Optional, so tests and the dialog tree can skip it.
The tree view's architecture. The Server sends JSON to the data source Adapter, which is product code. The Adapter merges nodes into the Node store, inside the design system. The Node store and the View state, which holds the expanded and selected sets, both feed rows(), the rows selector. rows() gives rows to the TreeView, which renders about 60 rows with ARIA. User input on the TreeView goes to the Controller. The Controller changes the View state, with select and open, and sends edits to the Node store. A note says the view state is saved to localStorage per user.ServerAPI, eventsproduct codeAdaptergetChildrendesign system: useTree + TreeNode storenodes, loadsrows()the walkView stateopen, selectedControllerkeys, dragTreeView60 rows, ARIAsetsrowsinputselect, openeditview state is saved tolocalStorage, per user
Only the Adapter knows the API, and only the TreeView touches the DOM. Server data (Node store) and per-view state (View state) are separate boxes on purpose: two trees can share the first and not the second.

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.

Data flow, top to bottom. Reads go down the left: the Server sends GET children responses and live events to the Adapter, which normalizes and merges nodes into the Store; the Store and the open set feed rows(); rows() gives the TreeView the 60 rows in the window. Writes go up the right: a drop on the TreeView goes to the Controller, which makes an optimistic edit in the Store; the Store calls move on the Adapter, which sends POST moves to the Server. A note says a 409 or 403 rolls the edit back in the Store.ServerAdapterStorerows()TreeViewControllerGET children, eventsnormalize, mergenodes + open set60 rows in windowmove()POST /movesdropoptimistic edit409 or 403:rollback in Store
There is one write path into the Store for everything: first loads, live events, optimistic edits and rollbacks. The view never fetches; it only renders rows and reports input.
  • One write path for everything. Initial loads, events, optimistic edits and rollbacks all end in the store's merge, move and remove. 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

Shared server data in one place, per-view state in another, short-lived gesture state in a third.
StateLives inLifetime
Nodes, childIds, versionsNode store (in memory, shared)Session; refreshed by events and stale checks
Load state per folder, in-flight promisesNode storeUntil loaded or retried
Expanded idsTree instance, saved to localStorageAcross reloads, per user and tree
Selected ids, anchor, focused idTree instance, or the parent if controlledUntil navigation; selection could go in the URL for shareable links
Filter queryTree instanceUntil cleared
Rename session, drag sessionInteraction controllerOne gesture
Pending optimistic edits with undo recordsNode storeUntil 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.

http
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": [...] } }
Children of one folder, sorted by the server and paged by cursor. The path endpoint reveals a deep node in one round trip.
http
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" }
A batch move with optimistic version checks, its error codes, and the event everyone receives. Each error code maps to a specific rollback message.

The component API

Decision What does the design system ship?

A complete component

Tree with props for data, icons, menus; it renders everything.

Pros

  • One line to use.
  • Consistent look.

Cons

  • Every new row design is a new prop.
  • Teams fork it when they need a badge or a second line.

A headless hook only

useTree returns rows and prop getters; teams render the DOM.

Pros

  • Full control of markup and style.

Cons

  • Each team re-implements the virtualized view, and some get ARIA wrong.

Headless hook plus a default component built on it

useTree holds the logic. Tree uses it, renders the virtualized rows with ARIA, and takes a renderNode for the row content.

Pros

  • Most teams use Tree and only customize row content.
  • Teams with unusual needs drop to the hook and keep the same behaviour.

Cons

  • Two layers to document and version.

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.

ts
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;
};
The Tree props. onMove and onRename return promises: rejecting one triggers the rollback.
  • getChildren is async and takes an AbortSignal, 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 expanded to implement "reveal active file"; the document app leaves it uncontrolled with defaultExpanded from storage.
  • renderNode gets state, not handlers. It receives isSelected, 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-label is 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.

A plan for 45 minutes. Move a deep dive earlier if the interviewer asks about it.
MinutesWhat to doWhat to draw
0 to 5Ask the scope questions: size, lazy or full, multi-select, editing, live updates, who builds rows.Nothing yet. Write the numbers down.
5 to 12Flat normalized store, childIds null until loaded, expanded set, the visible rows walk.The flat map and the rows it produces.
12 to 20Lazy loading with a per-folder state machine, dedupe, prefetch on intent, paging big folders.The state machine.
20 to 28Tree pattern: roles, roving tabindex, the arrow key model, level and position attributes.A small table of keys.
28 to 38One 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 45Architecture 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 use aria-activedescendant in 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 getChildren from memory. The store and view don't change. That's the point of the adapter boundary.
  • "VS Code shows src/main/java as 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 ordered childIds that stay null until 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-posinset all 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 useTree plus a default Tree with an async getChildren, a renderNode slot, controlled expanded and selected sets, and promise-returning onMove and onRename.

Check your answer

Tick what your own design covered. Pick the level you're interviewing for: each level includes the ones before it.

Junior 0 of 4
Mid-level 0 of 5

References

Concepts behind it

Try next