Skip to content
rvctv0.2.0

Compare

react-virtual-checkbox-tree vs headless-tree

Headless Tree is the closest thing to a philosophical sibling this library has, and it does more: drag-and-drop, async loading, renaming, search, keyboard drag-and-drop, all as opt-in feature modules. Its docs are also explicit that virtualization is not one of them.

Both are headless. Both do tri-state. Both keep the tree logic in a framework-free core. The difference is one sentence, and it is theirs, from their virtualization recipe: “Virtualization is not a included feature of Headless Tree, but you can easily pass this flat list to any virtualization library of your choice.”

That is a fair description of a real, working recipe — they flatten the tree for you and the integration is genuinely short. It is also the difference between a feature and an exercise, and this library exists on the other side of that line: the virtualizer is a dependency, the row heights are a prop, and the keyboard model was designed around rows that unmount.

Verified facts

Version
@headless-tree/core 1.7.0, published 2026-05-17npm
Weekly downloads
296,920 core / 275,559 reactnpm registry
GitHub stars
897repo
Virtualization
Not included — a documented recipetheir docs
Tri-state checkboxes
Yes — checkboxesFeature + propagateCheckedStatetheir docs
Bundle
12.3 kB + 1.2 kB gzipped, zero dependenciesBundlephobia
Licence
MIT, same as this library
Last verified
2026-09-10

What headless-tree does better

More than this library does, by a wide margin, and the gap is not close in most columns.

Ground this library does not hold

  • Drag and drop, including from the keyboard. dragAndDropFeature plus a dedicated keyboard drag-and-drop feature. There is nothing comparable here and there is not going to be.
  • Async children. asyncDataLoaderFeature loads children when a folder opens. This library has no onLoadChildren — you rebuild data and state survives, which is a workaround with a recipe page, not a feature.
  • Renaming. renamingFeature gives you inline edit state. Not present here.
  • Zero runtime dependencies. The core has none. This library has one, @tanstack/react-virtual, precisely because virtualization is not optional here.
  • A composable feature architecture. You import only the behaviours you use and the item instance grows methods to match. This library is one component and one class, take it or leave it.
  • Thirty times the install base and 141 published versions. 1.x, stable, widely used. This is v0.2.0 with a 0.x API that will still move.

If virtualization is just a recipe, why does it matter?

Because the recipe changes what the rest of the tree can assume, and those assumptions are load-bearing.

  • Focus. Once rows unmount, a row that owns DOM focus takes the focus with it when it scrolls out of view. This library never puts focus on a row: the container is the focusable element and the active row is tracked with aria-activedescendant, which is exactly why virtualization cannot break it. Bolting a virtualizer onto a tree whose rows are focusable elements is where this goes wrong, and it is a class of bug that does not announce itself in a demo.
  • Row measurement. estimateSize here takes a number or (index) => number, and overscan and indent are props. In a recipe those are yours to plumb through.
  • Flattening cost. The flat row list is cached against a structure version that a selection change deliberately does not bump — check a box in a 111,110-node tree and the row list is not rebuilt. Measured, that flatten is 25 ms at 111,110 nodes, so doing it per keystroke is the difference between a smooth tree and a janky one.
  • It does not combine with their nested rendering. Issue #185 is open on exactly this: the virtualization recipe uses the flat tree.getItems() list, and the nested-rendering recipe does not.
headless-tree.tsx
// headless-tree: features are opt-in modules, virtualization is not one of them.
import { useTree } from "@headless-tree/react";
import {
  checkboxesFeature,
  hotkeysCoreFeature,
  selectionFeature,
  syncDataLoaderFeature,
} from "@headless-tree/core";

const items: Record<string, { children?: string[]; name: string }> = {
  root:   { name: "root", children: ["docs", "src"] },
  docs:   { name: "docs", children: ["readme", "guide"] },
  readme: { name: "README.md" },
  guide:  { name: "guide.md" },
  src:    { name: "src", children: ["engine", "ui"] },
  engine: { name: "engine.ts" },
  ui:     { name: "ui", children: ["tree", "row"] },
  tree:   { name: "tree.tsx" },
  row:    { name: "row.tsx" },
};

export default function App() {
  const tree = useTree<{ name: string }>({
    rootItemId: "root",
    getItemName: (item) => item.getItemData().name,
    isItemFolder: (item) => Boolean(items[item.getId()].children),
    propagateCheckedState: true,
    dataLoader: {
      getItem: (id) => items[id],
      getChildren: (id) => items[id].children ?? [],
    },
    features: [syncDataLoaderFeature, selectionFeature, hotkeysCoreFeature, checkboxesFeature],
  });

  // Every item renders. Wiring a virtualizer around tree.getItems() is your job.
  return (
    <div {...tree.getContainerProps()}>
      {tree.getItems().map((item) => (
        <button {...item.getProps()} key={item.getId()}>
          <input type="checkbox" {...item.getCheckboxProps()} />
          {item.getItemName()}
        </button>
      ))}
    </div>
  );
}
rvct.tsx
// react-virtual-checkbox-tree: virtualization is not optional and not yours to wire.
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() {
  return (
    <Tree
      aria-label="Project files"
      data={data}
      estimateSize={32}
      height={320}
      overscan={8}
      onCheck={(checkedLeafIds) => console.log(checkedLeafIds)}
    />
  );
}

Both have search. They do different things. Headless Tree’s searchFeature is a find-and-highlight over the visible items. Search here is a filter that changes which rows exist, and it changes what checking a folder means: with a query active, checking a parent affects only the leaves currently visible. Filter to twelve matches, check the folder, clear the filter, and exactly those twelve are checked. A folder’s tri-state also summarizes only its visible children while filtered, because that is what the user is looking at. Clearing the query restores the expansion the user had before they started typing. Details in Search.

Where the two libraries actually overlap

On the core. If you want the tri-state math and the flattening without a renderer, react-virtual-checkbox-tree/engine is a plain class with subscribe(), no React import and no virtualizer in the bundle — the same shape of thing @headless-tree/core is, with a much smaller surface.

engine.ts
// The overlap: our headless core, with no React and no virtualizer in it.
// Runs in Node, in a test, or in a Server Component — there is no "use client" here.
import { CheckedState, Engine } from "react-virtual-checkbox-tree/engine";
import type { TreeDefinition, VisibleItem } from "react-virtual-checkbox-tree/engine";

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" },
};

function render(rows: VisibleItem[]) {
  for (const row of rows) {
    console.log("  ".repeat(row.level) + engine.getLabel(row.id), engine.getViewState(row.id));
  }
}

const engine = new Engine(data);

// subscribe() returns its own unsubscribe function.
const unsubscribe = engine.subscribe(() => render(engine.getVisibleItems()));

engine.expandAll();
engine.toggle("ui", true);

engine.getViewState("src") === CheckedState.Indeterminate; // true
engine.getViewState("ui") === CheckedState.Checked;        // true
engine.getAllChecked();   // ["tree", "row"] — leaves only, never folders
engine.getVisibleItems(); // flat rows: { id, isExpanded, isFolder, level, posInSet, setSize }

unsubscribe();

One asymmetry worth stating: Headless Tree’s core is framework-agnostic with React bindings shipped and others planned. This library’s engine is framework-agnostic in the same sense — it is just a class — but the only renderer that exists is the React one.

Use headless-tree if

You need drag-and-drop, async children, renaming, or a feature set you compose yourself — and your tree is small enough that rendering every item is fine, or you are happy to wire a virtualizer and own the focus and measurement details that come with it. It is more capable than this library in almost every direction except that one.

Use this one if

Your tree is large enough that virtualization is not optional, and you want it already integrated with the tri-state math, the ARIA tree and a focus model designed for rows that unmount. You are trading away drag-and-drop, async loading and renaming to get it.

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

← Back to the full capability matrix