Articles
Tree View Coding Interview: Stable IDs, Lazy Loading, and Keyboard Navigation
Prepare a tree view coding interview with stable node IDs, lazy-loading race guards, keyboard navigation, focus repair, and practical tests.
A tree view is not finished when recursion produces indented rows. In a tree view coding interview, the difficult part is preserving the meaning of a row while data changes: which node is expanded, which request may populate it, and which visible item owns keyboard focus. A correct solution gives each node a stable application ID, derives visible rows from the current tree and expansion set, rejects stale lazy-load results, and moves focus deliberately after a branch disappears.
The implementation below is an original extension that adds asynchronous children and a stricter focus contract.
Define the widget before you code
Ask whether the prompt means a nested disclosure list or a full ARIA treeview. A disclosure list can use ordinary buttons in nested lists; users Tab to each control. A treeview is a composite widget: one Tab stop enters it, and scripted arrow-key behavior moves among tree items. The W3C Tree View pattern defines that latter contract. Adding role="tree" to a list without implementing its keyboard behavior is not an accessibility upgrade.
For this exercise, assume an editable file explorer with remote child loading. A node has a server-issued, stable id, a label, a type, and hasChildren. The server may reorder siblings or delete a node. Expanding a closed branch starts a request; selecting a file is separate from merely focusing its row. State whether the product allows multi-selection, whether opening a file navigates away, and whether cached children survive collapse. Those answers change behavior but do not change the identity rule.
The three central invariants are: no two visible rows represent the same ID; only a visible row can own tree focus; and a late request cannot overwrite newer data for the same branch. Say those aloud before opening an editor. They are much easier to test than the vague promise that the tree “works.”
Use stable IDs for three different jobs
React's list guidance requires keys that remain stable when siblings insert, delete, or reorder. A domain node ID is a good key when it is unique among siblings and stable across data refreshes. But a React key only tells React how to reconcile rendered elements. Your own expansion, selection, focus, and request maps must also use the application ID explicitly.
Do not use a path such as root/0/2 as the primary ID if reordering changes it. A displayed path or breadcrumb can be derived from parent links. Do not use the label: two folders can be named “src.” Avoid Math.random() in render and array indices as persistent identities. Both make a moved node look like a different item and can transfer row-local state to the wrong file.
A compact state model is nodesById, childIdsByParentId, expandedIds, selectedIds, and focusedId. Request state can be a map from parent ID to {status, generation, controller}. The source data may be normalized or nested; normalization is convenient for parent lookups and applying one branch response. Store the minimum mutable state. A flat list of currently visible IDs is derived by a preorder traversal that descends only into expanded branches.
Derive visibility instead of hiding stale rows
Consider this small tree: root R has folders A and B; A has file A1; B has files B1 and B2. If R and B are expanded but A is closed, the visible sequence is R, A, B, B1, B2. Up/Down should move through exactly that sequence, not through all nodes in storage. When B collapses, its descendants disappear from the visible sequence even if cached data remains.
One pure function should flatten visibility from the current tree and expansion set. That function makes filtering, focus repair, and virtualization testable without DOM timing. If the tree is filtered, decide whether a matching descendant keeps its ancestors visible or whether matching a parent removes the whole subtree. The answer cannot be hidden inside display: none; it determines keyboard traversal and aria-posinset/aria-setsize information if supplied.
For very large trees, virtualize the visible sequence, not the full stored graph. Before doing so, decide how the mounted focus target remains available: roving tabIndex cannot focus an unmounted row, while aria-activedescendant needs a referenced element present in the DOM. Accessibility and virtualization therefore share the same visibility model rather than being separate polish steps.
Make lazy loading a versioned transition
On expansion, show the branch as expanded and represent loading explicitly. Start a request with a generation number for that parent. If it resolves, apply children only if the parent still exists and the generation is still current. A collapsed branch may cache a valid response if the product wants that; it must not automatically steal focus or reopen itself. If the node was removed or a refresh superseded the request, ignore the old response.
Aborting a fetch with AbortController is useful for avoiding unnecessary work, but the correctness guard is still the generation check. Cancellation can race with completion, and a custom loader may not honor abort at all. Treat abort as resource control and the ID/generation comparison as the state invariant.
| Time | Event | Current generation for B | Correct visible effect |
|---|---|---|---|
| t0 | Expand B; request 7 starts | 7 | B is expanded, loading |
| t1 | Collapse B; request 7 is aborted | 7 | Descendants are hidden |
| t2 | Expand B; request 8 starts | 8 | Loading again, focus stays on B |
| t3 | Request 8 returns B1, B2 | 8 | Cache children and show them |
| t4 | Request 7 returns late with old data | 8 | Ignore it; no visible or cached overwrite |
In an interview, explain what triggers a new generation: a fresh load, a forced refresh, or any invalidation that makes an older response irrelevant. Do not increment it merely because React re-rendered. Failure should leave a retryable error state attached to B, not an empty array that falsely means “this is a leaf.”
Implement the keyboard contract, not just arrow handlers
For a single-select treeview, W3C APG describes Up/Down as moving focus among visible nodes, Home/End as moving to the first/last visible node, Right as opening a closed node or moving to its first child if already open, and Left as closing an open node or moving to its parent. Type-ahead is recommended for larger trees. Selection may follow focus or be a separate action; state which policy you chose. Do not change selection on every focus movement accidentally.
Use role="tree", role="treeitem", and role="group" where the treeview pattern requires them, and communicate expansion with aria-expanded only for expandable nodes. Either use roving tabIndex on items or keep DOM focus on the tree and use aria-activedescendant; do not mix them halfway. Native buttons can still be appropriate for explicit row actions, but stop their events from unintentionally triggering row selection.
Loading status needs a perceivable announcement, such as a nearby polite status message. Avoid making every network event a noisy live-region update. Test with keyboard only first, then with a screen reader. W3C's keyboard-interface guidance is clear that naming a role does not provide its interaction model automatically.
Repair focus after collapse, delete, and filter
The hardest bug often appears after a correct keyboard implementation: a focused row is removed by a different action. If B1 is focused and its parent B collapses, move focus to B. If B1 is deleted while B2 survives, choose a documented successor such as the next visible sibling; otherwise choose the previous visible row or parent. If a filter removes the focused row, use the nearest visible survivor in the previous visible order. When no item remains, focus the tree container or the preceding filter control according to the product contract.
| Mutation | Former focused item | Fallback | Reason |
|---|---|---|---|
| Collapse parent | Descendant | Collapsed parent | Still visible and explains the disappearance |
| Delete leaf | Deleted row | Next visible sibling, then previous/parent | Keeps local navigation predictable |
| Filter results | Hidden row | Nearest surviving visible row | Avoids focus on an unmounted element |
| Delete entire tree | Any item | Tree container or adjacent control | No tree item is focusable |
Compute the fallback from a snapshot of the old and new visible sequences before committing the UI transition. Then focus after the new target has mounted. A blind document.querySelector('[role=treeitem]') fallback can jump the user to the root and may focus a row belonging to another widget. The invariant is not that focusedId is always non-null; it is that any non-null focused ID is visible and focusable.
Sketch an implementation interviewers can probe
Keep the pure model small, then connect it to React. A toggle clones the expandedIds set, changes exactly one ID, and derives a new visible sequence. A loader captures {parentId, generation}; success updates childIdsByParentId[parentId] only after validating current state. Selection updates selectedIds independently from focusedId. A focus-repair function receives oldVisibleIds, newVisibleIds, and oldFocusedId, returning either a visible ID or null.
Ask for the data contract before coding: Are IDs globally unique or only unique within a parent? Can a node move to another parent while preserving ID? Does hasChildren mean “known child exists” or “children might exist”? Is an empty successful response authoritative? Can two expansions load concurrently? These are implementation questions, not time-wasting ceremony: each prevents a different silent state bug.
For a minimal test, start with R, A, B, B1, B2. Assert that opening A leaves B expanded, moving B does not change its identity, and collapsing B moves focus from B1 to B. Then trigger two loads for B, resolve the second before the first, and assert that the old payload never becomes visible. Finally, delete B2 and check the chosen fallback. Use a rendered test for actual Tab/arrow behavior because a pure nextId function cannot prove the DOM focus or ARIA attributes are correct.
What a strong final answer sounds like
Summarize the contract in one minute: “I use server-stable node IDs for React keys and all application state. I derive visible rows from the tree and expanded set. Each lazy request has a parent ID and generation, so late results cannot overwrite a newer branch. I implement the chosen W3C treeview keyboard model and repair focus when a row becomes invisible. I test reordering, two out-of-order responses, collapse while focused, deletion, and keyboard navigation.”
That answer is stronger than a large component pasted without decisions. Practice it by implementing the filtered tree-view question, then add the two-request race and focus fallback as your own extensions. Keep disclosure-list and treeview semantics distinct, and state any assumptions the interviewer has not supplied.
Sources and Further Reading
- W3C ARIA Authoring Practices: Tree View pattern
- W3C ARIA Authoring Practices: Developing a keyboard interface
- React: Rendering lists and choosing keys
- MDN: AbortController
The state timeline and focus policy are original interview-practice examples.