Skip to content
rvctv0.2.0

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.

app.tsx
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.

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.

loading the comparison demo

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.

NodesBuild engineFlatten rowsCascade a checkRead selectionSearch keystroke
1,1101.3 ms0.3 ms3 µs53 µs142 µs
11,1109.6 ms2.2 ms1 µs320 µs957 µs
111,110115 ms25 ms1 µs3.7 ms11.5 ms
1,111,1101,955 ms684 ms1 µs75.8 ms169 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.