Back to Machine Coding
Machine Coding
easy
mid

How would you build a collapsible folder tree with nested checkboxes?

Render a recursive tree component; track expanded nodes and checkbox state. Checkboxes are tri-state: checking a folder cascades to children, and a parent shows indeterminate when children are mixed. Keep node state in a normalized structure and make it keyboard-accessible.

7 min read·~30 min to think through

This is a recursion + state-propagation problem. Two independent concerns: expand/collapse and tri-state checkboxes.

Data model

js
// Normalized for easy updates:
nodes = {
  '1': { id: '1', name: 'src', childIds: ['2','3'], parentId: null },
  '2': { id: '2', name: 'index.js', childIds: [], parentId: '1' },
}

Or a nested tree if it's read-mostly. Normalized makes propagation updates cleaner.

Recursive rendering

jsx
function TreeNode({ id }) {
  const node = useNode(id);
  const isExpanded = useExpanded(id);
  const checkState = useCheckState(id); // 'checked' | 'unchecked' | 'indeterminate'

  return (
    <li role="treeitem" aria-expanded={node.childIds.length ? isExpanded : undefined}>
      {node.childIds.length > 0 && (
        <button onClick={() => toggleExpand(id)} aria-label="toggle">
          {isExpanded ? '▼' : '▶'}
        </button>
      )}
      <Checkbox state={checkState} onChange={() => toggleCheck(id)} />
      <span>{node.name}</span>
      {isExpanded && node.childIds.map((cid) => <TreeNode key={cid} id={cid} />)}
    </li>
  );
}

The tri-state checkbox logic

The whole problem lives here:

  • Check/uncheck a folder → cascade down: every descendant takes the new value.
  • Toggle any node → recompute ancestors up: a parent is checked if all children are checked, unchecked if none, indeterminate otherwise.
  • indeterminate is not an HTML attribute — set it imperatively: inputRef.current.indeterminate = true.

Two clean implementations:

  1. Derived (recommended): store only leaf checked-state; compute folder state on read by walking children. No sync bugs — folders are pure derived state.
  2. Stored + propagated: store every node's state, and on each toggle run a down-cascade and an up-recompute. Faster reads, but you must keep it consistent.

Accessibility (don't skip — interviewers check)

  • role="tree" on the container, role="treeitem" on nodes, role="group" on child lists.
  • aria-expanded on folders, aria-checked ("mixed" for indeterminate).
  • Keyboard: Arrow keys to move/expand/collapse, Space to toggle the checkbox, roving tabindex so Tab enters the tree once.

Performance

  • Large trees → only render expanded subtrees (already done above), and virtualize the flattened visible list if it's huge.
  • React.memo on TreeNode so toggling one node doesn't re-render siblings.

Follow-up questions

  • Why is storing only leaf state and deriving folder state more robust?
  • How do you set the indeterminate state on a checkbox?
  • What ARIA roles and keyboard interactions does a tree need?
  • How would you virtualize a very large expanded tree?

Common mistakes

  • Trying to use indeterminate as a JSX attribute — it must be set imperatively via ref.
  • Storing folder checked-state and letting it drift from children.
  • No keyboard support or ARIA roles.
  • Re-rendering the whole tree on every single toggle.

Performance considerations

  • Only render expanded subtrees; memoize TreeNode so a toggle doesn't re-render siblings. For thousands of visible nodes, flatten and virtualize. Deriving folder state is O(children) per read but eliminates a whole class of sync bugs.

Edge cases

  • Empty folders — checking one with no children.
  • Very deep nesting hitting recursion/render limits.
  • Mixed selection states deep in the tree updating ancestors correctly.
  • Disabled nodes that shouldn't be affected by parent cascade.

Real-world examples

  • File explorers in VS Code / GitHub's file tree.
  • Permission/scope pickers, category selectors with parent-child selection.

Senior engineer discussion

Seniors call out the derived-vs-stored tradeoff for folder state (derived eliminates sync bugs), handle the indeterminate-via-ref detail without prompting, and treat accessibility (tree/treeitem roles, aria-checked='mixed', keyboard nav) as part of the spec, not an extra. They also flag virtualization for large trees and memoization to contain re-renders.

Related questions