Compare
react-virtual-checkbox-tree vs rc-tree / antd Tree
This is the one that genuinely does both. rc-tree virtualizes and does tri-state, plus async loading and drag-and-drop that this library does not have at all. What you pay for it is the markup: Ant Design's class names, Ant Design's DOM, and a virtualizer that silently does nothing if you forget the height prop.
Of everything on this site, rc-tree is the strongest competitor on features. It is checkable with a real indeterminate state, it has checkStrictly for when you want parents and children decoupled, it loads children asynchronously through loadData, it is draggable, and it has been shipping since 2015. If the feature list is the whole decision, it wins.
The decision usually is not the feature list. It is whether you want your tree to be Ant-shaped.
Verified facts
- Package, current
- @rc-component/tree 1.5.3, published 2026-09-10npm
- Package, legacy
- rc-tree 5.13.1, last published 2025-02-25npm
- Weekly downloads
- 2,223,750 rc-tree + 693,729 @rc-component/treenpm registry
- GitHub stars
- 1,272 react-component/tree · 99,453 ant-designrepo
- Virtualization
- Yes, but only when `height` is setNodeList.tsx
- Tri-state
- Yes — `checkable`, with `checkStrictly` to decoupleantd docs
- Bundle
- 27.4 kB rc-tree / 22.5 kB @rc-component/tree gzippedBundlephobia
- Licence
- MIT, same as this library
- Last verified
- 2026-09-10
What rc-tree and antd Tree do better
Three things this library cannot do at all, and one it does worse.
Ground this library does not hold
- Async children.
loadDatareturns a promise and children arrive when a folder opens, with retry handling built in. There is noonLoadChildrenhere — you rebuilddataas pages land and nothing is lost, but that is a different shape of solution. - Drag and drop.
draggable,onDrop,allowDrop. Absent here, permanently. - checkStrictly. Parents and children fully decoupled, and
checkedKeysthat include folder keys. This library cannot express a checked folder at all —onCheckreturns leaf IDs only. - Everything around the tree. Directory tree mode,
showLine,onRightClick,titleRender,fieldNamesto map your own field names, and — if you use antd — a whole product’s worth of matching components. - Eleven years and 2.9 million downloads a week across the two packages. rc-tree first published in May 2015. This library has eight downloads a week and a 0.x version number, and pretending otherwise would be silly.
The height prop is a trap worth knowing about
virtual defaults to true, which reads like virtualization is on by default. It is not, quite. The list component checks both:
// react-component/tree, src/NodeList.tsx
if (virtual === false || !height) {
return list;
}
return list.slice(0, Math.ceil(height / itemHeight) + 1);No height, no virtualization — every node renders and nothing warns you. It is a reasonable design (the component cannot virtualize a container it cannot measure), and it is also the single most common reason someone reports that “antd Tree is slow with 50,000 nodes”. The antd docs also note that enabling it costs you horizontal scrolling.
Here the virtualizer is not conditional. height defaults to "100%", estimateSize defaults to 32 and accepts (index) => number, overscan defaults to 8, and rows scroll horizontally because each row is width: max-content with a min-width of 100%.
// antd Tree: checkable + virtualized — but only if you pass height.
// npm i antd
import { Tree } from "antd";
import { useState, type Key } from "react";
const treeData = [
{ key: "docs", title: "docs", children: [{ key: "readme", title: "README.md" }, { key: "guide", title: "guide.md" }] },
{
key: "src",
title: "src",
children: [
{ key: "engine", title: "engine.ts" },
{ key: "ui", title: "ui", children: [{ key: "tree", title: "tree.tsx" }, { key: "row", title: "row.tsx" }] },
],
},
];
export default function App() {
const [checkedKeys, setCheckedKeys] = useState<Key[]>([]);
return (
<Tree
checkable
checkedKeys={checkedKeys}
// Without height, NodeList returns the full list and nothing is virtualized.
height={320}
onCheck={(keys) => setCheckedKeys(keys as Key[])}
treeData={treeData}
/>
);
}// react-virtual-checkbox-tree: virtualized whether or not you remember a prop.
// npm i react-virtual-checkbox-tree
import { useState } from "react";
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", "guide"] },
readme: { id: "readme", label: "README.md" },
guide: { id: "guide", label: "guide.md" },
src: { id: "src", label: "src", children: ["engine", "ui"] },
engine: { id: "engine", label: "engine.ts" },
ui: { id: "ui", label: "ui", children: ["tree", "row"] },
tree: { id: "tree", label: "tree.tsx" },
row: { id: "row", label: "row.tsx" },
};
export default function App() {
const [checked, setChecked] = useState<string[]>([]);
return (
<Tree
aria-label="Project files"
checkedItems={checked}
data={data}
estimateSize={32}
height={320}
onCheck={setChecked}
/>
);
}What the Ant markup costs
rc-tree is the unstyled engine under antd’s Tree, so “use rc-tree directly” sounds like the headless option. It is not: the DOM structure and the class name scheme are the library’s, the switcher and checkbox are its elements, and you style around them with a prefixCls. That is themeable, not headless.
This library emits a flat row per visible node and nothing else. The styling contract is data attributes: data-rvct-tree on the container, and data-rvct-row, data-state, data-level, data-expanded, data-leaf and data-active on rows. Three render props — renderItem, renderCheckbox, renderExpander — replace the row body, the checkbox and the expander outright. See Styling.
Search filters here; filterTreeNode marks
rc-tree’s filterTreeNode returns a boolean per node and tags matching nodes so you can highlight them. The non-matching nodes stay in the list. Searching here is a filter: searchQuery removes rows that do not match and do not contain a match, auto-expands the branches that do, and restores your original expansion when you clear the box.
It also changes what checking means, which is the part worth reading twice. With a query active, checking a folder toggles only the leaves currently visible. Filter to twelve matches, check the folder, clear the filter — exactly those twelve are checked, and the other eight hundred files in that folder are untouched. A folder’s indeterminate state also summarizes only its visible children while filtered.
// Search here removes rows rather than marking them, and checking a folder
// while filtered touches only the leaves you can currently see.
import { useState } from "react";
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", "guide"] },
readme: { id: "readme", label: "README.md" },
guide: { id: "guide", label: "guide.md" },
src: { id: "src", label: "src", children: ["engine", "ui"] },
engine: { id: "engine", label: "engine.ts" },
ui: { id: "ui", label: "ui", children: ["tree", "row"] },
tree: { id: "tree", label: "tree.tsx" },
row: { id: "row", label: "row.tsx" },
};
export default function App() {
const [query, setQuery] = useState("");
return (
<>
<input onChange={(event) => setQuery(event.target.value)} placeholder="Filter files" value={query} />
<Tree
aria-label="Project files"
data={data}
height={320}
minSearchChars={3}
onCheck={(checkedLeafIds) => console.log(checkedLeafIds)}
searchQuery={query}
searchScope="all"
/>
</>
);
}searchScope defaults to "all", so folder names match too and a matching folder carries its whole subtree into the filtered view. Pass "leaves" for leaf-only matching. Matching is diacritic-insensitive: resume finds Résumé.
Selection storage at scale
checkedKeys is an array of every checked key. That is a fine contract and it is what most trees expose, including this one by default. The difference is that here it is not the storage: selection is a sparse map of explicit assignments, so checking a folder of 50,000 leaves writes one entry, and 1–3 µs is the cascade cost at 1,110 nodes and at 1,111,110 nodes alike. When materializing the full list becomes the bottleneck — 3.7 ms at 111,110 nodes — you read getCheckedSubtrees() instead and keep the sparse form. Persist a selection covers it.
Use rc-tree / antd Tree if
You are already on Ant Design, or you need async children, drag-and-drop or checkStrictly. It does all three and this library does none of them, and 2.9 million weekly downloads across its two packages means the bugs have been found. Just remember to pass height.
Use this one if
You want the markup to be yours rather than Ant's, you want virtualization that cannot be accidentally switched off, you need search that filters and a selection that survives filtering, or your tree is large enough that the checked-keys array is itself the cost.
Every fact on this page was checked on 2026-09-10 and links to its source. Numbers move and libraries ship; if something here is out of date or unfair, open an issue and it gets fixed. Corrections that make a competitor look better are the most welcome kind.
Other comparisons
- react-checkbox-treeThe incumbent. Same mental model, no virtualization path.
- react-arboristVirtualized and excellent, but has no checkboxes.
- headless-treeSame philosophy, wider scope — and virtualization is your job.
- MUI X Rich Tree ViewVirtualization is behind the Pro licence.
- Writing it yourselfThe real competitor. The four bugs you will write.