Skip to content
rvctv0.2.0

Compare

react-virtual-checkbox-tree vs react-arborist

react-arborist has no checkboxes. That is the whole comparison: it is a better file-manager tree than this will ever be, and it does not ship the one thing this library exists to do. Issue #352 — “Tracking: Checkbox selection & indeterminate (parent) state” — is open.

Both are virtualized. Both are TypeScript-first. Both let you render the row. They part company on purpose: react-arborist is built around manipulating a tree — drag to reorder, rename in place, create, delete — and its selection model is row selection, the kind you get from a file explorer. This library is built around choosing a set of leaves, and the tri-state cascade is the entire product.

If you are choosing between them and your requirement contains the word “drag”, stop reading and use react-arborist.

Verified facts

react-arborist version
3.16.0, published 2026-07-25npm
Weekly downloads
433,646npm registry
GitHub stars
3,704repo
Checkboxes / indeterminate
Not built in — #352 open since 2026-06-07#352
Virtualization
Yes, via react-windowrepo
Bundle
30.8 kB gzipped, 5 dependenciesBundlephobia
Licence
MIT, same as this library
Last verified
2026-09-10

What react-arborist does better

Nearly everything that is not a checkbox.

Ground this library does not hold

  • Drag and drop, properly. Reordering, dropping into folders, a drop cursor you can replace with renderCursor, a drag preview you can replace with renderDragPreview. This library has none of it and it is an explicit non-goal, not a to-do.
  • Inline renaming and CRUD. onCreate, onRename, onMove, onDelete and an editing state on every node. That is a file manager in a box. Here you would build all of it around the tree.
  • Range selection. Shift-click and shift-arrow select a contiguous run of rows. This library toggles one row at a time with Space and offers Ctrl/Cmd+A for everything — there is no range selection.
  • A richer node API. NodeApi gives you node.toggle(), node.edit(), node.select(), node.isEditing, node.nextSibling and the rest, plus a TreeApi for the whole thing. The equivalent here is the Engine, which is deliberately smaller.
  • Fifty times the install base. 433,646 downloads a week and 3,704 stars against eight and a handful. Its edge cases have been found.

Can I just add checkboxes to react-arborist?

You can render one, and then you own the tri-state logic. That is more than it sounds. The maintainer has consolidated four separate requests — #312, #173, #267 and #190 — into a single tracking issue #352, which is the clearest possible signal that people keep wanting this and it is not there yet.

Here is the honest shape of the DIY version next to the built-in one. The left column is a complete, working tri-state cascade — and it is still missing the ARIA tree the right column emits (role="treeitem" with aria-checked="mixed", aria-level, aria-setsize and aria-posinset), and leavesUnder() walks the entire subtree on every render of every row.

arborist-checkbox.tsx
// react-arborist: the checkbox is yours to build, and so is the cascade.
import { useCallback, useState } from "react";
import { Tree, type NodeApi } from "react-arborist";

type Item = { children?: Item[]; id: string; name: string };

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

/** You write this. It is the part that goes wrong at depth 3. */
function leavesUnder(node: NodeApi<Item>): string[] {
  if (node.isLeaf) return [node.id];
  return (node.children ?? []).flatMap(leavesUnder);
}

function stateOf(node: NodeApi<Item>, checked: Set<string>) {
  const leaves = leavesUnder(node); // walks the whole subtree, on every render
  const n = leaves.filter((id) => checked.has(id)).length;
  if (n === 0) return "unchecked";
  return n === leaves.length ? "checked" : "indeterminate";
}

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

  const toggle = useCallback((node: NodeApi<Item>, next: boolean) => {
    setChecked((prev) => {
      const out = new Set(prev);
      for (const id of leavesUnder(node)) {
        if (next) out.add(id);
        else out.delete(id);
      }
      return out;
    });
  }, []);

  return (
    <Tree data={data} height={320} rowHeight={32} width={320}>
      {({ node, style }) => {
        const state = stateOf(node, checked);
        return (
          <div style={style}>
            <input
              aria-label={node.data.name}
              checked={state === "checked"}
              onChange={(event) => toggle(node, event.target.checked)}
              ref={(el) => {
                if (el) el.indeterminate = state === "indeterminate";
              }}
              type="checkbox"
            />
            <span onClick={() => node.toggle()}>{node.data.name}</span>
          </div>
        );
      }}
    </Tree>
  );
}
rvct.tsx
// react-virtual-checkbox-tree: the cascade is the library.
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}
    />
  );
}

The scaling difference is not the line count, it is leavesUnder(). Recomputing a parent’s state by walking its descendants is O(subtree) per row per render. This library stores selection as sparse assignments and derives state with a memoized post-order walk that skips any branch containing no assignments, which is why a cascade costs 1–3 µs whether the tree holds a thousand nodes or a million. The mechanism is in Checkbox semantics.

Can I use both?

Yes, and it is a reasonable answer. The react-virtual-checkbox-tree/engine entry point ships the flattening, the tri-state math and the search filter as a plain class with no React and no virtualizer in it. You can drive react-arborist’s rendering and let the Engine own the checkbox state — engine.getViewState(id) per row, engine.toggle(id, next) on click, engine.subscribe() to re-render. See the engine-only example.

Use react-arborist if

You are building a file explorer, an outline editor, a layers panel, or anything where users move nodes around. Drag-and-drop, inline rename and range selection are all first class there and absent here — and its 433,000 weekly downloads mean the sharp edges are already filed off.

Use this one if

Selection is the point and manipulation is not: a file picker, a permissions matrix, a faceted filter, an export dialog. You want the indeterminate math, ancestor-aware search and an ARIA tree without writing the cascade — and you are fine having no drag-and-drop at all.