Headless·React 18 & 19·5.6 kB gzipped·1 dependency·MIT
Checkbox trees that don’t die at 100,000 nodes.
Headless, virtualized, tri-state. Cascade a selection across a million nodes in microseconds while the browser holds a few dozen rows. Bring your own checkbox.
$ npm i react-virtual-checkbox-tree- nodes
- 10,000
- folders
- 611
- leaves
- 9,389
- rows expanded
- 0
- rows in the DOM
- 0
- checked
- 0
- last op
- —
Every number above is measured live in your browser. The row count comes from querySelectorAll("[data-rvct-row]") on this page — open devtools and check it.
Thirty seconds
Paste this in
A flat map of nodes and a root entry. That is the whole data model — no nesting, no adapters, no provider to mount.
import { Tree, type TreeDefinition } from "react-virtual-checkbox-tree";
const data: TreeDefinition = {
__root__: { id: "__root__", label: "root", children: ["docs", "src"] },
docs: { id: "docs", label: "docs", children: ["readme"] },
readme: { id: "readme", label: "README.md" },
src: { id: "src", label: "src", children: ["engine"] },
engine: { id: "engine", label: "engine.ts" },
};
export default function App() {
return (
<Tree
aria-label="Project files"
data={data}
height={320}
onCheck={(checkedLeafIds) => console.log(checkedLeafIds)}
/>
);
}Flat, not nested. Lookups are O(1), IDs stay stable across updates, and you can build the map straight from a SQL result or a flat API response without recursion.
Only leaves are checkable. onCheck hands you leaf IDs and never folder IDs, because a folder is not checked — its state is computed from what is under it, every time it renders.
Uncontrolled by default. Add checkedItems, expandedItems or searchQuery to take control of any one of them independently.
Passing a brand-new data object on every render is safe: the structure is swapped in place and the user’s selection and open folders survive it.
The second thing it does
Search that respects what you can see
Type, and matching nodes surface with their ancestors already unfolded. Check a folder while filtered and only the leaves on screen are affected — hidden ones keep whatever state you left them in. Clear the box and the folders you had open come back.
Why this one
What you actually get
Every headless tree makes virtualization your homework. Every virtualized tree makes a design system your problem. This ships both, wired together, and stays out of your markup.
The checkbox math is the product
Parent state is derived, never stored. Checking a folder of 50,000 leaves is one map write — measured at 1–3 µs whether the tree holds a thousand nodes or a million. Indeterminate states stay correct through every cascade.
Virtualized from the first commit
Rows render through @tanstack/react-virtual. A 200,000-node tree mounts the same handful of DOM elements as a 20-node one, and the counter under the demo above is read straight out of the document.
Search that doesn't destroy your selection
Filter to 12 matching leaves, check the parent folder, clear the filter: exactly those 12 are checked. Everything you couldn't see is untouched — and clearing the query gives you back the folders you had open.
Keyboard and screen readers, properly
A real ARIA tree: role="tree", aria-level / setsize / posinset on every row, tri-state aria-checked, arrow keys, Home/End, type-ahead, and aria-activedescendant so virtualization can never eat the focus.
No CSS. None.
There is no stylesheet to import and no theme to override. Swap in your own checkbox, expander and row body through three render props, and style rows with the data-state / data-level attributes the library already emits.
The engine works without React
Import react-virtual-checkbox-tree/engine and you get the flattening, tri-state and search filtering as a plain class with subscribe() — no renderer, no virtualizer, server-safe. Build your own UI on top, or drive it from a test.
Proof, not adjectives
Watch it break without virtualization
The same tree, rendered both ways. Turn virtualization off and the page stalls on your machine, not in a screenshot. Capped well below the hero's node count on purpose — the honest version of this demo shouldn't crash your phone.
Measured
Where the time goes
Median of five runs on Node 26 / Apple Silicon, against the published build. Reproduce with npm run bench — the script is in the repo and these numbers come straight out of it.
| Nodes | Build engine | Flatten rows | Cascade a check | Read selection | Search keystroke |
|---|---|---|---|---|---|
| 1,110 | 1.3 ms | 0.3 ms | 3 µs | 53 µs | 142 µs |
| 11,110 | 9.6 ms | 2.2 ms | 1 µs | 320 µs | 957 µs |
| 111,110 | 115 ms | 25 ms | 1 µs | 3.7 ms | 11.5 ms |
| 1,111,110 | 1,955 ms | 684 ms | 1 µs | 75.8 ms | 169 ms |
The column that matters is cascade: it does not grow with the tree, because checking a subtree writes a single assignment instead of a boolean per node. The columns that do grow are the honest ones — building the engine is linear in node count, and asking for the full list of checked leaf IDs has to materialize that list. At a million nodes you should drive the Engine directly and read the sparse selection instead.
Before you install
When not to use this
I would rather you find this out here than after npm i.
You need drag-and-drop or inline rename
This is a selection control, not a file manager. If you need to reorder nodes, react-arborist is the better tool and I would genuinely rather you used it.
You need to load children on expand
The engine wants the whole map up front. You can rebuild data as pages arrive and state is preserved, but there is no first-class async loading yet.
Your data is a graph, not a tree
A node appearing under two parents is modelled as a tree: the last parent wins, and the checked state follows that branch. Duplicate shared nodes under distinct IDs first — the library warns you in development when it spots this.
You have a hundred nodes and a deadline
If your tree is small and you already have a component library with a tree in it, use that. The engineering here starts paying for itself somewhere north of a few thousand nodes, or when you need the search-selection semantics.
You can't tolerate a 0.x API
v0.2.0. The prop surface will keep moving before 1.0. Pin the exact version.
You need checkable folders
Folders derive their state and can’t hold their own. “Grant this whole department” as a value distinct from “all its members” is not expressible yet — it’s on the roadmap.
Three days into the same search?
That’s how this got written. If it saves you the week it cost me, a star is the only thanks it needs — and an issue is worth more than one.