Skip to content
rvctv0.2.0

Compare

react-virtual-checkbox-tree vs MUI X Rich Tree View

MUI X gives you tri-state checkbox selection for free, in the MIT community package, and it is genuinely good. What it puts behind the Pro licence is virtualization — $299 per developer per year, which for a team of six is $1,794 a year to stop rendering rows you cannot see.

People assume the split runs the other way, so it is worth saying plainly: checkbox selection and parent/child propagation are in the free package. checkboxSelection plus selectionPropagation={{ descendants: true, parents: true }} gets you cascading selection with an automatic indeterminate state, at no cost, from a library with a full-time team behind it. If your tree is a few hundred items and you are already on Material UI, that is the correct answer and this page is not trying to change your mind.

The split is virtualization. That lives in RichTreeViewPro, from @mui/x-tree-view-pro, which needs an MUI X Pro licence.

Verified facts

Community version
@mui/x-tree-view 9.13.0, published 2026-09-04npm
Weekly downloads
1,043,769npm registry
Tri-state, community tier
Yes — checkboxSelection + selectionPropagationMUI docs
Virtualization
RichTreeViewPro only, on by default in v9MUI docs
Pro licence
$299 per developer per yearMUI pricing
Bundle
21.2 kB gzipped community / 40.8 kB Pro, before @mui/materialBundlephobia
Virtualization request
Issue #9685, closed 2026-01-23#9685
Last verified
2026-09-10

What MUI X does better

It is a commercial product with a team, a release train and a support contract. That shows.

Ground this library does not hold

  • Checkable folders that survive the round trip. With selectionPropagation.parents, selecting every child selects the parent, and the parent’s ID comes back in selectedItems. This library’s onCheck returns leaf IDs and nothing else, by design. If your API wants “the whole folder” as a value, MUI expresses it and this does not.
  • Label editing. The Rich Tree View has built-in item label editing. There is no equivalent here.
  • Pro: lazy loading and item reordering. Async children on expand and drag-to-reorder, both supported, both documented, both absent here at any price.
  • A design system that already matches your app. If the rest of the product is Material, the tree looks right on day one. Here you write every pixel — which is the selling point and also the work.
  • Documentation, i18n, and a support channel. Localization, an accessibility story maintained by people whose job it is, and Pro support if something breaks in production.
  • A million downloads a week against eight. Whatever you are about to hit, someone hit it, filed it, and it has a milestone.

What the $299 actually buys, and what it doesn’t

The Pro tier is not a rip-off — you are also getting lazy loading and reordering, and MUI X Pro covers the Data Grid too. The question is narrower: if virtualization is the only Pro feature you need, you are paying a per-seat annual fee for a rendering strategy. This library does that part for free and under MIT, at 5.6 kB gzipped (12.6 kB with its one dependency) against 40.8 kB for @mui/x-tree-view-pro before @mui/material and Emotion are counted.

You give up the design system, the editing, the reordering and the support contract to get it. That is the trade in one sentence.

mui.tsx
// MUI X: tri-state is free. Virtualization is RichTreeViewPro.
// npm i @mui/x-tree-view @mui/material @emotion/react @emotion/styled
import { RichTreeView } from "@mui/x-tree-view/RichTreeView";
import { useState } from "react";

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

export default function App() {
  const [selected, setSelected] = useState<string[]>([]);

  return (
    <RichTreeView
      checkboxSelection
      items={items}
      multiSelect
      onSelectedItemsChange={(_event, ids) => setSelected(ids as string[])}
      selectedItems={selected}
      selectionPropagation={{ descendants: true, parents: true }}
    />
  );
}
rvct.tsx
// react-virtual-checkbox-tree: virtualized by default, no design system attached.
// 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}
    />
  );
}

Can I keep the Material look?

Yes — that is what renderCheckbox is for. Render @mui/material’s own Checkbox inside this tree and you get Material pixels with virtualized rows. The one rule you must not break is spreading a11yProps onto the control.

material-skin.tsx
// Keep the MUI look, drop the MUI tree: render their Checkbox in renderCheckbox.
import Checkbox from "@mui/material/Checkbox";
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 default function App() {
  return (
    <Tree
      aria-label="Project files"
      data={data}
      estimateSize={36}
      height={320}
      renderCheckbox={({ a11yProps, checkedState }) => (
        // a11yProps is { "aria-hidden": true, tabIndex: -1 } and MUST be spread:
        // the ROW carries role="treeitem" and aria-checked, so the visual control
        // has to stay out of the tab order and out of the accessibility tree.
        <Checkbox
          {...a11yProps}
          checked={checkedState === CheckedState.Checked}
          disableRipple
          indeterminate={checkedState === CheckedState.Indeterminate}
          onChange={() => {
            /* the row owns the interaction; this keeps React from warning */
          }}
          size="small"
        />
      )}
    />
  );
}

a11yProps is { "aria-hidden": true, tabIndex: -1 }. The row carries role="treeitem" and aria-checked, so a visible, focusable checkbox inside it makes every row announce twice and puts hundreds of controls in the tab order. Spread it. Same rule for renderExpander. Full details in Render props.

One typing detail: checkedState is the CheckedState string enum, not a bare string. Compare against CheckedState.Checked and CheckedState.Indeterminate — TypeScript rejects checkedState === "checked" even though the runtime value is exactly that string, which is also what lands in data-state on the row.

Where the difference stops being about money

Above roughly 50,000 nodes the comparison changes character. This library stores selection as sparse assignments rather than a list of selected IDs, so checking a folder of 50,000 leaves is one map write — measured at 1–3 µs at every tree size from 1,110 to 1,111,110 nodes. Any component whose public contract is “here is the array of selected items” has to build that array on every change; here you can opt out of building it and read getCheckedSubtrees() instead. That is a documented, measured difference, not a claim about MUI’s internals.

Use MUI X Rich Tree View if

Your app is already Material UI, your tree is in the hundreds or low thousands, or you need label editing, lazy loading or item reordering. The community tier's tri-state selection is free and good, and if you are buying MUI X Pro for the Data Grid anyway, the virtualized tree comes with it at no extra cost.

Use this one if

You need virtualization without a per-seat licence, you are not on Material UI, you want the markup to be yours, or your tree is large enough that materializing the selected-IDs array on every click is itself the bottleneck.