Skip to content
rvctv0.2.0

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. loadData returns a promise and children arrive when a folder opens, with retry handling built in. There is no onLoadChildren here — you rebuild data as 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 checkedKeys that include folder keys. This library cannot express a checked folder at all — onCheck returns leaf IDs only.
  • Everything around the tree. Directory tree mode, showLine, onRightClick, titleRender, fieldNames to 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:

NodeList.tsx
// 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.tsx
// 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}
    />
  );
}
rvct.tsx
// 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.

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