Skip to content
rvctv0.2.0

Compare

react-virtual-checkbox-tree vs writing it yourself

Under a few hundred nodes, writing it yourself is the right answer, and this page is not going to pretend otherwise. Eighty lines, no dependency, no 0.x API to track, and it does exactly what your product needs. Ship it.

The real competitor to this library is not another library. It is the afternoon you were going to spend on a recursive component and a Set. That afternoon is a good investment when the tree is small, the requirements are shallow and nobody has asked for search yet.

What follows is not an argument against it. It is the list of four bugs the hand-rolled version develops as the tree grows, in the order they show up, with the line in this repository that handles each one. If none of the four describe your situation, close this tab and go write the component.

The honest framing

Hand-rolled version
~80 lines, 0 dependencies, 0 kB
This library
5.6 kB gzipped, 1 dependency, v0.2.0source
Where hand-rolling is correct
Roughly under 500 nodes, no search
Where it starts hurting
Depth 3+, or a filter, or ~2,000 nodes
Last verified
2026-09-10

The version you are about to write

This is a complete, working checkbox tree. It cascades, it shows indeterminate parents, it is readable, and for a settings panel with twelve options it is strictly better than installing anything.

naive-tree.tsx
// The version almost everyone writes first. It is fine. Really.
import { useState } from "react";

type Node = { children?: Node[]; id: string; label: string };

const data: Node[] = [
  { id: "docs", label: "docs", children: [{ id: "readme", label: "README.md" }, { id: "guide", label: "guide.md" }] },
  {
    id: "src",
    label: "src",
    children: [
      { id: "engine", label: "engine.ts" },
      { id: "ui", label: "ui", children: [{ id: "tree", label: "tree.tsx" }, { id: "row", label: "row.tsx" }] },
    ],
  },
];

function leavesUnder(node: Node): string[] {
  return node.children?.length ? node.children.flatMap(leavesUnder) : [node.id];
}

function stateOf(node: Node, checked: Set<string>): "checked" | "indeterminate" | "unchecked" {
  if (!node.children?.length) return checked.has(node.id) ? "checked" : "unchecked";
  const hits = node.children.filter((child) => checked.has(child.id)).length;
  if (hits === 0) return "unchecked";
  return hits === node.children.length ? "checked" : "indeterminate";
}

function Row({
  checked,
  level,
  node,
  onToggle,
}: {
  checked: Set<string>;
  level: number;
  node: Node;
  onToggle: (node: Node, next: boolean) => void;
}) {
  const state = stateOf(node, checked);
  return (
    <>
      <div style={{ paddingLeft: level * 20 }}>
        <input
          checked={state === "checked"}
          onChange={(event) => onToggle(node, event.target.checked)}
          ref={(el) => {
            if (el) el.indeterminate = state === "indeterminate";
          }}
          type="checkbox"
        />
        {node.label}
      </div>
      {node.children?.map((child) => (
        <Row checked={checked} key={child.id} level={level + 1} node={child} onToggle={onToggle} />
      ))}
    </>
  );
}

export default function App() {
  const [checked, setChecked] = useState<Set<string>>(new Set());

  const onToggle = (node: Node, next: boolean) => {
    setChecked((prev) => {
      const out = new Set(prev);
      for (const id of leavesUnder(node)) next ? out.add(id) : out.delete(id);
      return out;
    });
  };

  return (
    <div>
      {data.map((node) => (
        <Row checked={checked} key={node.id} level={0} node={node} onToggle={onToggle} />
      ))}
    </div>
  );
}

Bug 1: the indeterminate state stops at depth 2

stateOf asks whether a folder’s direct children are in the checked set. Grandchildren are not in that set, so a folder whose grandchildren are partly checked reads as fully unchecked.

With tree.tsx checked and nothing else: ui correctly shows indeterminate, because tree is in the set and row is not. But src looks at engine and ui, finds neither in the set, counts zero, and renders unchecked. The user checked a file and the folder above the folder above it says nothing is selected.

Click through it — the left pane is exactly the code above:

Check tree.tsx in either pane, then look at src. Both panes hold the same one checked leaf. Only one of them says so.

Hand-rolled

A folder's state is counted off its direct children only.

react-virtual-checkbox-tree

Post-order derivation, memoized, skipping branches with no assignments.

checked leaves []

The fix looks small, and it is — the first time. Recurse on states instead of membership:

fix-1.ts
// The fix for bug 1: derive a folder's state from its children's *states*,
// not from whether its children appear in a flat set of ids.
type Node = { children?: Node[]; id: string; label: string };

function stateOf(node: Node, checked: Set<string>): "checked" | "indeterminate" | "unchecked" {
  if (!node.children?.length) return checked.has(node.id) ? "checked" : "unchecked";

  let allChecked = true;
  let anyChecked = false;
  for (const child of node.children) {
    const state = stateOf(child, checked); // recurses to the leaves, every render
    if (state === "indeterminate") return "indeterminate";
    if (state === "checked") anyChecked = true;
    else allChecked = false;
  }
  if (allChecked) return "checked";
  return anyChecked ? "indeterminate" : "unchecked";
}

That is correct and it is O(subtree) per folder per render. In this repository the same derivation is an iterative post-order walk with a memo cache and a fast path that skips any branch containing no assignments at all — computeState in engine.ts. It is iterative rather than recursive for a reason the tests cover: a 10,000-deep chain must not blow the call stack.

Bug 2: every checkbox click re-renders every node

The naive tree renders one DOM element per node and recomputes every parent’s state on every click, so a check costs O(nodes) twice over.

At 200 nodes you will never notice. At 5,000 the click feels mushy. At 50,000 the tab stops responding, and the profiler will point at stateOf — the fixed version, the one that recurses to the leaves for every folder on every render.

There are two separate problems hiding in that sentence, and they need two separate fixes.

  • The DOM. Rows have to be virtualized, which means a scroll container, a measured row height, absolute positioning and a spacer. Here that is @tanstack/react-virtual wired up in tree.tsx, with estimateSize, overscan and indent exposed as props. Roughly twenty rows are mounted whether the tree holds a hundred nodes or a million.
  • The state. Selection is stored as sparse assignments, not as a set of checked ids: checking a folder of 50,000 leaves writes one map entry and then drops any stale assignments below it. That is toggleFull and clearSubtreeAssignments. Measured cost: 3 µs at 1,110 nodes, 1 µs at 1,111,110.

There is a third piece that is easy to miss. A selection change does not move any row, so the flattened row list must not be rebuilt when one happens. The engine tracks a structureVersion and a selectionVersion separately and notifySelection() deliberately does not touch the former, which is why checking a box in a 111,110-node tree does not pay the 25 ms flatten. A hand-rolled version that keeps one version counter pays it on every click.

Bug 3: select-all while filtered selects things nobody could see

The moment you add a search box, leavesUnder() becomes wrong, because it does not know the filter exists.

The user filters 800 files down to 12, clicks the folder checkbox to take “all of them”, clears the filter — and has selected 800 files. This one does not throw, does not warn, and does not show up in a demo with nine nodes. It shows up as a support ticket about a bulk operation that touched the wrong records. Switch the demo above to the second tab, type tree, and click src.

fix-3.ts
// The fix for bug 3, in outline: the cascade has to know about the filter.
type Node = { children?: Node[]; id: string; label: string };

function visibleLeavesUnder(node: Node, visible: Set<string>): string[] {
  if (!visible.has(node.id)) return [];
  if (!node.children?.length) return [node.id];
  return node.children.flatMap((child) => visibleLeavesUnder(child, visible));
}

// ...and stateOf has to summarize visible children only, or a folder showing
// 2 of its 800 files reads as indeterminate when both visible ones are checked.
// You now have two state functions and two cascade functions, and they must
// agree about what "visible" means at all times.

In this repository that is toggleVisibleOnly and getStateFiltered: while a query is active, a toggle walks the filtered child map instead of the real one, and a folder’s tri-state summarizes only its visible children. Filter to twelve matches, check the folder, clear the filter, and exactly those twelve are checked. Clearing the query also restores the expansion the user had before they started typing — a snapshot taken when search activates and put back when it clears. See Search.

Bug 4: the selection dies when the data updates

A new data array arrives from the server, the component rebuilds whatever it derived from the old one, and the user’s selection and open folders go with it.

This one has several disguises. Storing checked on the node objects themselves, so a fresh fetch wipes it. Keying rows by index, so an insert at the top shifts every checkbox down one. A useMemo over data that rebuilds a parent map and drops the ids that no longer resolve. Or the honest version: a useEffect that resets state when data changes, because keeping it consistent was harder than clearing it.

Passing a brand-new data object to <Tree> is safe. The structure is swapped in place by engine.setData(), not rebuilt around: assignments and expansion for nodes that still exist are kept, state for nodes that vanished is pruned, the assignment counts are rebuilt, and the active query is re-run against the new structure. The tests assert all four of those, and it is what makes loading data in pages work without an onLoadChildren prop.

What else you would end up writing

The four bugs are the ones that bite. These are the things that simply take time, and none of them are hard — they are just all of them.

  • The ARIA tree. role="tree" with aria-multiselectable, role="treeitem" with aria-level, aria-setsize, aria-posinset and aria-checked="mixed". Virtualization flattens the DOM, so the nesting a screen reader would infer from role="group" wrappers is not there — aria-posinset and aria-setsize are how the tree stays navigable anyway.
  • Focus that survives unmounting. If a row owns DOM focus and the virtualizer scrolls it away, the focus goes with it. Here focus stays on the container and the active row is tracked with aria-activedescendant.
  • The keyboard. ArrowUp/Down/Left/Right with the collapse-to-parent behaviour, Home, End, Space, Enter, * to expand everything, Ctrl/Cmd+A to select all or clear, and type-ahead with a 600 ms buffer.
  • Data you did not sanitize. A child id with no entry of its own, a node listed under two parents, a cycle. The engine always drops dangling references, and in development it warns about them, warns that only the last parent of a two-parent node wins, and throws on a cycle rather than hanging.
  • Diacritics. resume should find Résumé.

And the case for still writing it yourself

  • Zero dependencies beats 5.6 kB. No supply chain, no version bumps, no breaking change in someone else’s 0.x.
  • You can change anything. Checkable folders, a fourth state, per-node disabling, a cascade that skips disabled leaves — all trivial in code you own, and several of them are impossible here.
  • It is v0.2.0 with eight downloads a week. The API will move before 1.0. If you cannot tolerate that, your own eighty lines are more stable than this package is.
  • Most trees never grow. The 500-node settings tree you are worried about is, statistically, going to still be a 500-node settings tree in three years.

Use your own code if

The tree is small, the depth is shallow, there is no search box, the data does not change under the user, and nobody is going to run an accessibility audit on it. That is most trees. Eighty lines and no dependency is a genuinely good outcome and you should not feel bad about it.

Use this one if

You have hit two or more of the four bugs above, or you can see them coming: the tree is deeper than two levels, there is a filter, the data refetches, or the node count has a comma in it. Everything described on this page is already written, tested and measured.

A complete working tree in one file →