Migrating from react-checkbox-tree
Two things change: your nested nodes array becomes a flat map keyed by id, and every visual
decision moves from a prop to a render prop. Everything else — controlled checked, controlled
expanded, tri-state parents, leaf-only cascade — works the same way and often needs no thought at
all.
This page is written against react-checkbox-tree v2.0.2, published 2026-05-28, and was last verified on 2026-09-10. Their prop names come from
their README; ours are all verifiable in src/tree.tsx.
Before and after
The same tree, twice. First in react-checkbox-tree:
import { useState } from "react";
import CheckboxTree from "react-checkbox-tree";
import "react-checkbox-tree/lib/react-checkbox-tree.css";
const nodes = [
{
value: "docs",
label: "docs",
children: [
{ value: "readme", label: "README.md" },
{ value: "guide", label: "guide.md" },
],
},
{
value: "src",
label: "src",
children: [
{ value: "engine", label: "engine.ts" },
{
value: "ui",
label: "ui",
children: [
{ value: "tree", label: "tree.tsx" },
{ value: "row", label: "row.tsx" },
],
},
],
},
];
export function Files() {
const [checked, setChecked] = useState<string[]>([]);
const [expanded, setExpanded] = useState<string[]>(["docs", "src"]);
return (
<CheckboxTree
checked={checked}
expanded={expanded}
nodes={nodes}
onCheck={(next) => setChecked(next)}
onExpand={(next) => setExpanded(next)}
/>
);
}And then here:
"use client";
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 function Files() {
const [checked, setChecked] = useState<string[]>([]);
const [expanded, setExpanded] = useState<string[]>(["docs", "src"]);
return (
<Tree
aria-label="Project files"
checkedItems={checked}
data={data}
expandedItems={expanded}
height={320}
onCheck={setChecked}
onExpand={setExpanded}
/>
);
}Four differences worth naming:
- No stylesheet import. There is no CSS to import, and no Font Awesome to load. Rows are unstyled until you style them.
datais a flat map. The__root__entry is required; itschildrenare your top-level rows, and the root itself is never rendered.heightis required in practice. The scroll container defaults toheight: "100%", so if its parent has no height you will see nothing. react-checkbox-tree grows to fit its content; a virtualized list cannot.onCheckandonExpandtake one argument. There is notargetNodesecond argument.
How do I convert the nodes array?
Paste this. It walks the nested array once and returns a TreeDefinition you can hand straight to
data.
import type { TreeDefinition, TreeItem } from "react-virtual-checkbox-tree";
/** The node shape react-checkbox-tree accepts. */
export type RctNode = {
children?: RctNode[];
className?: string;
disabled?: boolean;
icon?: unknown;
label: unknown;
showCheckbox?: boolean;
title?: string;
value: number | string;
};
/**
* Flattens a react-checkbox-tree `nodes` array into a `TreeDefinition`.
*
* Everything the old component read off the node itself — icon, className,
* disabled, title — is preserved under `item.data` and comes back untouched in
* `renderItem`.
*/
export function convertNodes(nodes: RctNode[], rootId = "__root__"): TreeDefinition {
const data: TreeDefinition = { [rootId]: { id: rootId, label: "root", children: [] } };
// An explicit stack rather than recursion: a deeply nested export should not
// overflow the call stack halfway through a migration.
const stack: Array<{ nodes: RctNode[]; parentId: string }> = [{ nodes, parentId: rootId }];
while (stack.length > 0) {
const frame = stack.pop()!;
const childIds: string[] = [];
for (const node of frame.nodes) {
const id = String(node.value);
if (data[id]) {
throw new Error(
`convertNodes: duplicate node value "${id}". IDs must be unique across the whole tree.`
);
}
const hasChildren = Array.isArray(node.children) && node.children.length > 0;
// `label` must be a string here — it is what search matches against. A
// React-element label moves into `data` and is drawn by `renderItem`.
const item: TreeItem = {
id,
label: typeof node.label === "string" ? node.label : id,
data: {
className: node.className,
disabled: node.disabled ?? false,
icon: node.icon,
richLabel: typeof node.label === "string" ? undefined : node.label,
showCheckbox: node.showCheckbox ?? true,
title: node.title,
},
};
if (hasChildren) item.children = [];
data[id] = item;
childIds.push(id);
if (hasChildren) stack.push({ nodes: node.children!, parentId: id });
}
data[frame.parentId].children = childIds;
}
return data;
}Three conversion decisions this makes for you, in case you want different ones:
- A node with
children: []becomes a leaf, and leaves are the only checkable nodes here. If your data has empty-array parents that must stay parents, give them a placeholder child instead — see Loading data in pages. - Duplicate values throw rather than silently winning. This library models a tree, not a graph: a node listed under two parents keeps only the last parent it saw, and warns in development.
- Non-string labels are replaced by the id and stashed at
item.data.richLabel. Search matcheslabel, so a React element there would match nothing.
Prop-by-prop mapping
| react-checkbox-tree | react-virtual-checkbox-tree | Notes |
|---|---|---|
nodes | data | Nested array becomes a flat map keyed by id. Use convertNodes() above. |
checked | checkedItems | Both hold leaf ids. Folder ids passed here are ignored. |
expanded | expandedItems | onExpand also reports __root__; handing that list straight back is safe and settles in one pass. |
onCheck(checked, node) | onCheck(checkedLeafIds) | One argument. There is no targetNode. |
onExpand(expanded, node) | onExpand(expandedFolderIds) | One argument. There is no targetNode. |
icons | renderCheckbox, renderExpander, renderItem | No icon-slot object, and no Font Awesome dependency. |
iconsClass | — | Nothing is themed by class name. |
showNodeIcon | renderItem | Draw your own icon from item.data. |
nativeCheckboxes | (already the default) | The built-in checkbox is a native input type="checkbox". |
onlyLeafCheckboxes | renderCheckbox returning null for folders | See below. |
checkModel="all" | — | onCheck returns leaf ids only, always. Rebuild the "all" list from the engine — see below. |
noCascade | — | Not supported. Checking a folder always cascades to its subtree. |
optimisticToggle | — | Always behaves as true: toggling a mixed folder checks everything under it. |
disabled | — | No disabled prop, and no per-node disabled. |
expandDisabled | — | Not supported. Clicking a folder row always toggles it. |
expandOnClick | (always on) | Clicking a folder row expands it. Not configurable. |
onClick | renderItem | Attach your own handler and call stopPropagation() on it. |
onContextMenu | — | Not supported. Explicit non-goal. |
showExpandAll | ref.expandAll() and ref.collapseAll() | The buttons are yours to render. |
checkKeys | — | Space toggles the check. Enter toggles the check on a leaf, expansion on a folder. Not configurable. |
direction="rtl" | — | No direction prop. Rows indent with paddingInlineStart, so an ambient dir="rtl" flips indentation, but nothing else is tested for RTL. |
lang | — | No localization table. The only built-in strings are the + and − expander glyphs, replaceable via renderExpander. |
name, nameAsArray | — | No hidden form input. Serialize the onCheck array yourself. |
showNodeTitle | renderItem | Set title on your own element. |
id | className, style | Row DOM ids are generated from React's useId and are not configurable. |
node.value | map key and item.id | Must be a string in the map. convertNodes() calls String() on it. |
node.label | item.label | Must be a string — it is what search matches. |
node.children | item.children | An array of ids, not of nested objects. |
node.className | renderItem | |
node.disabled | — | Not supported at any level. |
node.icon | item.data plus renderItem | |
node.showCheckbox | renderCheckbox returning null | |
node.title | renderItem | |
react-checkbox-tree.css | — | No stylesheet ships. Style [data-rvct-row] yourself. |
Props with no counterpart in react-checkbox-tree, because they exist to make virtualization and
search work: estimateSize, overscan, indent, height, searchQuery, searchScope,
minSearchChars, aria-label, aria-labelledby, className, style, and the ref API
(expandAll, collapseAll, focusId, scrollToId, getEngine).
How do I use my own checkbox?
Through renderCheckbox. The one rule that matters:
"use client";
import {
CheckedState,
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 function Files() {
return (
<Tree
aria-label="Project files"
data={data}
height={320}
// The `onlyLeafCheckboxes` equivalent: folders simply get no checkbox.
renderCheckbox={({ a11yProps, checkedState, isFolder }) =>
isFolder ? null : (
<input
{...a11yProps}
checked={checkedState === CheckedState.Checked}
onChange={() => {
/* the row owns the interaction */
}}
type="checkbox"
/>
)
}
renderExpander={({ a11yProps, isExpanded, onToggle }) => (
<button
{...a11yProps}
onClick={(event) => {
event.stopPropagation();
onToggle();
}}
type="button"
>
{isExpanded ? "▾" : "▸"}
</button>
)}
/>
);
}Hiding a folder's checkbox is visual only: the row still exposes aria-checked and Space still
toggles it. That is deliberate — an assistive-technology user should not lose the ability to select a
whole folder because of a styling choice.
renderItem changed shape in 0.2.0 and is now called with an object, not with the item. The object is
{ checkedState, id, isActive, isExpanded, isFolder, item, level } — everything you would otherwise
have to look up yourself:
"use client";
import { CheckedState, 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 function Files() {
return (
<Tree
aria-label="Project files"
data={data}
height={320}
// Not renderItem(item) — one object, destructured.
renderItem={({ checkedState, isFolder, item, level }) => (
<span
style={{ fontWeight: isFolder ? 600 : 400 }}
title={`level ${level}, ${checkedState}`}
>
{item.label}
{checkedState === CheckedState.Indeterminate ? " (partial)" : null}
</span>
)}
/>
);
}How do I get the checkModel="all" list?
Ask the engine for each node's state. onCheck gives you leaf ids only and will not change.
"use client";
import { useCallback, useRef } from "react";
import {
CheckedState,
Tree,
type TreeDefinition,
type TreeRef,
} 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 function Files() {
const treeRef = useRef<TreeRef>(null);
// Equivalent to react-checkbox-tree's checkModel="all": every fully checked
// node, folders included. Costs one pass over `data`, so call it when you
// submit, not on every keystroke.
const checkedAll = useCallback(() => {
const engine = treeRef.current?.getEngine();
if (!engine) return [];
return Object.keys(data).filter(
(id) => id !== "__root__" && engine.getState(id) === CheckedState.Checked
);
}, []);
return (
<>
<Tree aria-label="Project files" data={data} height={320} ref={treeRef} />
<button onClick={() => console.log(checkedAll())} type="button">
Log selection
</button>
</>
);
}Checking src in that tree gives ["src", "engine", "ui", "tree", "row"] from checkedAll(), and
["engine", "tree", "row"] from onCheck.
What do you gain?
- Virtualization, wired up. Rows render through
@tanstack/react-virtualwith nothing to install and nothing to configure beyondestimateSize. A 100,000-node tree mounts the same handful of DOM elements as a 20-node one. - A selection model that does not grow with the tree. Checking a folder of 50,000 leaves writes one entry, not 50,000. Cascading a check measures at 1–3 µs whether the tree holds 1,110 nodes or 1,111,110.
- Search in the box.
searchQueryfilters to matches and their ancestors, matches folder names by default, strips diacritics, and restores your expansion state when the query clears. There is no filtering in react-checkbox-tree at all. - No CSS, no icon font. Nothing to override and nothing to load. Rows expose
data-state("checked","unchecked"or"indeterminate"),data-level,data-expanded,data-leafanddata-active; the container exposesdata-rvct-tree. - A server-safe entry point.
react-virtual-checkbox-tree/enginehas no React and no"use client"banner, so you can compute a selection in a Server Component, a route handler or a Node script without pulling in the renderer or the virtualizer. - Passing a new
dataobject is cheap to reason about. The structure is swapped in place and selection, expansion and the active query survive for nodes that still exist.
What do you lose?
Honestly, and in the order you will notice:
- Maturity. react-checkbox-tree has been on npm since February 2016, is at v2.0.2 across 52 releases, and has been read by far more people than this has. This is v0.2.0 with a 0.x API that will change before 1.0.
noCascade. There is no way to make a parent toggle stop at the parent. Cascade is the model.checkModel="all". Recoverable, as shown above, but it costs a pass overdatainstead of being free.disabled, at both levels. No component-widedisabled, no per-nodedisabled. You can render a greyed row throughrenderItem, but it will still toggle when clicked.expandDisabled. Clicking a folder row always toggles expansion.optimisticToggle={false}. Toggling a mixed folder always checks everything under it.- The form-input integration. No
name, nonameAsArray, no hiddeninput. In a plain HTML form you serialize the selection yourself. lang,iconsClass,direction. No localization table, no Font Awesome 4/5 switch, no documented RTL support.onClickandonContextMenu. No row click callback and no context menu hook. You can attach handlers insiderenderItem, but there is no context-menu machinery.- Drag-and-drop, inline rename, context menus — none of these existed in react-checkbox-tree either, and they are explicit non-goals here too.
- Lazy children. Neither library has an
onLoadChildren. Here the workaround is documented; see Loading data in pages. - Checkable folders. Folders derive their state and can never hold one of their own, so "grant this whole department" as a value distinct from "all its current members today" is not expressible.
- Graphs. A node listed under two parents keeps only the last parent seen, with a development warning.
Stay on react-checkbox-tree if…
- Your tree is a few hundred nodes and renders fine. Virtualization buys you nothing, and you would be trading a mature library for a 0.x one to fix a problem you do not have.
- You need
noCascadeoroptimisticToggle={false}. Neither is expressible here. - You need per-node
disabled. This is the single most common blocker in practice. - You post the tree in a plain HTML form and rely on
name/nameAsArray. - You need
langfor localization ordirection="rtl"support you can point at documentation for. - Your labels are React elements and you rely on them being searched or sorted — labels here are strings by design.
If none of those apply and your tree is big, the migration is an afternoon: run convertNodes(),
rename four props, delete the CSS import, and add a height.