Engine only
You need the tri-state math, the flattening and the ancestor-aware search, but not the rows the library renders — because you have a table, a canvas, a mobile list, or a design system that owns row markup outright. So take the engine and leave the renderer.
Why a tree here. The hard part of a checkbox tree is not the DOM, it is keeping every ancestor's state correct while a selection cascades through a hundred thousand descendants. That part is a plain class with a subscribe() method, and it does not care what draws the result.
The demo
No <Tree> on this page. The rows below are a map() over getVisibleItems(), re-rendered through useSyncExternalStore.
- nodes
- 8
- rows in the DOM
- 8
- checked leaves
- 0
- virtualized
- no
The source
Complete, including the keyboard map. This is the honest total cost of the headless route.
"use client";
import type { KeyboardEvent } from "react";
import { useCallback, useId, useState, useSyncExternalStore } from "react";
import { CheckedState, Engine } from "react-virtual-checkbox-tree/engine";
const files = {
__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 function HandRolledTree() {
// One engine for the life of the component. Rebuilding it on every render
// would throw away the selection and the open folders.
const [engine] = useState(
() => new Engine(files, { initialExpanded: ["docs", "src", "ui"] })
);
const [activeId, setActiveId] = useState<null | string>(null);
const reactId = useId();
const domId = (id: string) => reactId + "-" + id;
const subscribe = useCallback((cb: () => void) => engine.subscribe(cb), [engine]);
// Two counters in one string. structureVersion changes when rows move,
// appear or disappear; selectionVersion changes when only the boxes do.
// Returning a plain string keeps getSnapshot referentially stable by value,
// which is what useSyncExternalStore requires.
const snapshot = useCallback(
() => engine.getStructureVersion() + ":" + engine.getSelectionVersion(),
[engine]
);
useSyncExternalStore(subscribe, snapshot, snapshot);
const rows = engine.getVisibleItems();
const move = (index: number) => {
if (index < 0 || index >= rows.length) return;
setActiveId(rows[index].id);
};
// Every one of these lines is behaviour <Tree> would have given you.
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
const index = activeId ? engine.indexOf(activeId) : -1;
const row = index >= 0 ? rows[index] : null;
switch (event.key) {
case "ArrowDown":
event.preventDefault();
return move(index < 0 ? 0 : index + 1);
case "ArrowUp":
event.preventDefault();
return move(index < 0 ? rows.length - 1 : index - 1);
case "ArrowRight":
event.preventDefault();
if (!row || !row.isFolder) return;
if (!row.isExpanded) engine.setExpandedFor(row.id, true);
else move(index + 1);
return;
case "ArrowLeft": {
event.preventDefault();
if (!row) return;
if (row.isFolder && row.isExpanded) return engine.setExpandedFor(row.id, false);
const parent = engine.getParent(row.id);
if (parent) move(engine.indexOf(parent));
return;
}
case "Home":
event.preventDefault();
return move(0);
case "End":
event.preventDefault();
return move(rows.length - 1);
case " ":
if (!row) return;
event.preventDefault();
engine.toggle(row.id, engine.getViewState(row.id) !== CheckedState.Checked);
return;
default:
return;
}
};
return (
<div
aria-activedescendant={activeId ? domId(activeId) : undefined}
aria-label="Project files"
aria-multiselectable
onKeyDown={onKeyDown}
role="tree"
tabIndex={0}
>
{rows.map((row) => {
const state = engine.getViewState(row.id);
return (
<div
aria-checked={
state === CheckedState.Indeterminate ? "mixed" : state === CheckedState.Checked
}
aria-expanded={row.isFolder ? row.isExpanded : undefined}
// aria-level is 1-based; row.level is 0-based.
aria-level={row.level + 1}
aria-posinset={row.posInSet}
aria-setsize={row.setSize}
id={domId(row.id)}
key={row.id}
onClick={() => {
setActiveId(row.id);
if (row.isFolder) engine.toggleExpanded(row.id);
else engine.toggle(row.id, state !== CheckedState.Checked);
}}
role="treeitem"
style={{ paddingLeft: row.level * 18 + 8 }}
>
<span aria-hidden="true">{row.isFolder ? (row.isExpanded ? "−" : "+") : " "}</span>
<span aria-hidden="true">
{state === CheckedState.Checked
? "☑"
: state === CheckedState.Indeterminate
? "▣"
: "☐"}
</span>
{engine.getLabel(row.id)}
</div>
);
})}
</div>
);
}The same engine on the server
The /engine entry point carries no "use client" banner and no React import: 3.1 kB gzipped, against 5.6 kB for the full component — and it drops the @tanstack/react-virtual dependency, which neither figure counts, on top of that.
// Runs in Node, in a Server Component, or in a test. No React involved.
import { CheckedState, Engine } from "react-virtual-checkbox-tree/engine";
import { files } from "./files";
const engine = new Engine(files);
engine.toggle("src", true);
engine.getAllChecked(); // ["row", "tree", "engine"] — leaves only, in
// traversal order, never folder IDs
engine.getState("src"); // CheckedState.Checked
engine.getCheckedSubtrees(); // [{ id: "src", checked: true }] — one row to persist
engine.getNodeCount(); // 8
engine.getLeafCount(); // 5
engine.setSearchQuery("tree");
engine.isSearchActive(); // true
engine.getMatchCount(); // 1
engine.getVisibleItems().map((row) => row.id); // ["src", "ui", "tree"]API exercised
new Engine(data, opts)opts takes initialExpanded, minSearchChars, rootId and searchScope. The root is always expanded and never rendered.
subscribe(cb)Fires on every change and returns an unsubscribe function. Pair it with useSyncExternalStore, or call it from anything at all.
getStructureVersion() / getSelectionVersion()Two counters, deliberately separate: a selection change moves no rows, so the flattened list stays cached across every click.
getVisibleItems()The flattened rows: { id, isExpanded, isFolder, level, posInSet, setSize }. posInSet and setSize exist because a virtualized tree has no group wrappers for a screen reader to count.
getViewState(id)The state to render, summarizing only search-visible children while a query is active. getState(id) ignores the filter and answers against the whole tree.
toggle / setExpandedFor / indexOf / getParentEverything the keyboard handler needs. indexOf returns -1 for a node that is not currently visible.