# React Tree View: Checkboxes, Lazy Loading, Drag to Move

> Build a React tree view with tri-state checkboxes, lazy children, a filter that keeps parents, drag to move and full keyboard support on desktop and phones.

- Author: [Serban Rusu](https://wingo-ui.com/blog/authors/serban), Founder of Wingo UI
- Published: Oct 19, 2026
- Category: [Component guides](https://wingo-ui.com/blog/category/components)
- Reading time: 10 min
- Canonical: https://wingo-ui.com/blog/react-tree-view

## TL;DR

A usable React tree view needs the WAI-ARIA tree keyboard model, tri-state checkboxes that cascade, children that load on first expand, a filter that keeps the path to each match, and a drag to move with a keyboard alternative. The Wingo UI Tree View does all of it from one items array of nested nodes; the File Manager builds a full documents screen on top of it.

You need a React tree view for folders, product categories or a permissions screen. A recursive component with a chevron and some indentation takes ten minutes. Then the requirements show up: a checkbox on a folder should check everything under it, a branch with 2,000 children should load only when it opens, a filter should find "invoice" three levels deep, someone wants to drag a file into another folder, and the whole thing has to work with arrow keys and on a phone. This guide covers what a production tree needs, how the common options compare, and the props that matter in the Wingo UI [Tree View](https://wingo-ui.com/components/tree-view), with working code for each part.

## What should a React tree view handle?

Seven things. A recursive component with a chevron covers only the first:

1. **Expand and collapse** with an animation that does not jump the page.
2. **Keyboard navigation** following the [WAI-ARIA tree view pattern](https://www.w3.org/WAI/ARIA/apg/patterns/treeview/): one Tab stop, arrows to move, Right to open, Left to close or go to the parent, type-ahead.
3. **Selection**, single or multiple, separate from checking.
4. **Tri-state checkboxes** where a partly checked folder shows a dash.
5. **Lazy children** that load on first expand, with a loading state and a way to retry.
6. **A filter** that keeps each match's parents so you can see where it lives.
7. **Drag to move**, plus a keyboard way to do the same move.

A phone adds two more: rows big enough to tap, and a row menu that opens as a bottom sheet instead of a tiny popover.

## How do the React tree view libraries compare?

They split on three features: lazy children, drag to move and checkboxes. MUI X keeps lazy loading and reordering in its paid Pro package, react-arborist ships drag and drop and a filter but its README does not mention checkboxes, and shadcn/ui has no tree component at all, only a sidebar block. Facts below come from each project's public docs and README, as of October 2026.

| Option | What it is | Checkboxes | Lazy children | Drag to move | Filter |
|---|---|---|---|---|---|
| [react-arborist](https://github.com/brimdata/react-arborist) | MIT package, virtualized, needs `react-dnd` as a peer dependency | Not mentioned in the README | Not mentioned in the README | Drag and drop sorting | `searchTerm` prop |
| [MUI X Tree View](https://mui.com/x/react-tree-view/) | MIT Community package plus a commercial Pro package | [`checkboxSelection` with `selectionPropagation`](https://mui.com/x/react-tree-view/rich-tree-view/selection/) | Pro | Reordering is Pro | Not listed on the overview page |
| [shadcn/ui sidebar-11](https://ui.shadcn.com/blocks/sidebar) | A sidebar block "with a collapsible file tree" | Not in the block | Not in the block | Not in the block | Not in the block |
| [ReUI Tree](https://reui.io/components/tree) | shadcn registry component on `@headless-tree/react` | Shown in a permissions example | A loading pattern in its FAQ, no example | Through dnd kit, per its FAQ | Type-ahead only |
| [Wingo UI Tree View](https://wingo-ui.com/components/tree-view) | Pro source component, `npx wingo-ui@latest add tree-view` | Tri-state with `cascade` | `loadChildren` with Retry | Mouse, long press and Alt + arrows | `searchable` with highlighted matches |

If your app already runs on MUI, its free RichTreeView with checkbox selection is the shorter path. If you want a free virtualized tree and are fine styling it yourself, react-arborist is a good fit. People searching for a react tree view shadcn version usually want code they own in their repo: ReUI, [shadcn-tree-view](https://github.com/MrLightful/shadcn-tree-view) (MIT, with drag and drop) and the Wingo UI Tree View all install as source. The rest of this guide covers the Wingo UI component.

## What does the Tree View need to render?

One `items` array of nested nodes. Each node has an `id` that is unique across the whole tree, a `label`, and optional `children`.

```bash
npx wingo-ui@latest add tree-view
```

```tsx
"use client";

import { TreeView, type TreeNode } from "@/components/ui/tree-view";

const files: TreeNode[] = [
  {
    id: "app",
    label: "app",
    children: [
      { id: "layout", label: "layout.tsx" },
      { id: "page", label: "page.tsx" },
    ],
  },
  { id: "components", label: "components", children: [{ id: "button", label: "button.tsx" }] },
  { id: "readme", label: "README.md" },
];

export function ProjectFiles({ onOpen }: { onOpen: (id: string) => void }) {
  return (
    <TreeView
      items={files}
      folderIcons
      defaultExpandedKeys={["app"]}
      onAction={(node) => onOpen(node.id)}
      labels={{ tree: "Project files" }}
    />
  );
}
```

That is a file tree React developers will recognize: `folderIcons` gives parents a folder icon that opens with them and gives leaves a file icon. `onAction` runs when a file gets Enter, a double click or a tap on a phone. Without it, a node with `href` follows its link. A node can also carry `icon`, `badge`, `description`, `disabled` and its own `actions` for the ⋯ menu, which is the [Dropdown Menu](https://wingo-ui.com/components/dropdown-menu) entry shape.

Your data probably comes from a database as flat rows with a `parentId`. Convert it once:

```ts
import type { TreeNode } from "@/components/ui/tree-view";

type Row = { id: string; parentId: string | null; name: string };

export function toTree(rows: Row[]): TreeNode[] {
  const nodes = new Map<string, TreeNode>(rows.map((row) => [row.id, { id: row.id, label: row.name }]));
  const roots: TreeNode[] = [];
  for (const row of rows) {
    const node = nodes.get(row.id)!;
    const parent = row.parentId ? nodes.get(row.parentId) : undefined;
    if (parent) (parent.children ??= []).push(node);
    else roots.push(node);
  }
  return roots;
}
```

## How do tri-state checkboxes work in a React tree view?

Turn on `checkboxes` and keep `checkedKeys` in state. With `cascade` on (the default), checking a folder checks every loaded, enabled node under it. A folder is checked when all its children are and shows a dash when only some are. Disabled rows are skipped, so "owner only" permissions stay unchecked when someone checks the whole group. The rows announce the state as `aria-checked`, with `mixed` for the dash.

A react tree view checkbox setup for a role editor looks like this:

```tsx
"use client";

import { useState } from "react";
import { Button } from "@/components/ui/button";
import { TreeView, flattenTree, type TreeNode } from "@/components/ui/tree-view";

export function RoleEditor({ permissions }: { permissions: TreeNode[] }) {
  const [checked, setChecked] = useState<string[]>([]);
  // with cascade, fully checked folders are in the keys too; save only the leaves
  const leafIds = new Set(flattenTree(permissions).filter((row) => !row.hasChildren).map((row) => row.id));

  return (
    <form action="/api/roles/sales-agent" method="post">
      <TreeView
        items={permissions}
        checkboxes
        selectionMode="none"
        checkedKeys={checked}
        onCheckedKeysChange={setChecked}
        defaultExpandedKeys={["sales"]}
        labels={{ tree: "Permissions" }}
      />
      <input type="hidden" name="permissions" value={checked.filter((id) => leafIds.has(id)).join(",")} />
      <Button type="submit">Save role</Button>
    </form>
  );
}
```

Checking and selecting are separate. The box checks, the row selects. With `selectionMode="none"`, a click on a file row checks it too, while a click on a folder row only opens it, which is what you want on a permissions screen. To compute a folder's state on the server with the same rule, use the exported `getCheckedState(node, checkedKeys)`.

In the demo below, check the Invoices 2026 folder, then uncheck one of its files and watch the folder turn to a dash. Type in the filter field to narrow the tree.

> Live demo (Tree View): Check Invoices 2026 to check its three files, uncheck one to see the dash, then type contract in the filter: the path to each match opens. Try it at [Tree View](https://wingo-ui.com/components/tree-view) and install it with `npx wingo-ui@latest add tree-view`.

## How do I load children lazily?

Mark a node with `hasChildren` and leave out `children`. On first expand, the tree calls `loadChildren(node)` and shows a spinner row. If the promise rejects, a Retry row takes its place. Results are cached inside the tree.

```tsx
"use client";

import { TreeView, type TreeNode } from "@/components/ui/tree-view";

const roots: TreeNode[] = [
  { id: "drive", label: "Shared drive", hasChildren: true },
  { id: "archive", label: "Archive", hasChildren: true },
];

async function loadChildren(node: TreeNode): Promise<TreeNode[]> {
  const response = await fetch(`/api/folders/${node.id}/children`);
  if (!response.ok) throw new Error("Could not load");
  return (await response.json()) as TreeNode[];
}

export function SharedDrive() {
  return <TreeView items={roots} folderIcons loadChildren={loadChildren} labels={{ tree: "Shared drive" }} />;
}
```

Subfolders you have not fetched yet can come back with `hasChildren: true`. One limit to plan for: the filter only sees loaded nodes. If most of the tree is still unloaded, search on the server and show the results instead.

## How does the filter work?

`searchable` adds a filter field above the tree. Matches keep their parents, the path to each one opens, and the matched letters turn bold. Case and diacritics never matter, so "skoda" finds Škoda. Typing is debounced by 150ms. ArrowDown moves from the field into the tree and Escape clears it. On a phone the field uses 16px text, so iOS Safari does not zoom in when it gets focus.

You can control the text with `filter` and `onFilterChange`, for example to keep it in the URL. A node whose `label` is JSX needs a `textValue`, or the filter and type-ahead have nothing to match against.

## How do I add drag and drop to a React tree view?

Turn on `draggable` and handle `onMove`. A line shows where the row will land: before or after a row, or a ring around a folder it goes into. Hovering a closed folder opens it after a short pause, and dropping a folder into itself is refused. On touch, a long press of 250ms lifts the row so a swipe still scrolls the page; the [dnd kit mobile guide](https://wingo-ui.com/blog/dnd-kit-mobile-touch-drag) explains why that delay matters.

That covers react tree view drag and drop with a pointer. The keyboard path is Alt + ArrowUp and Alt + ArrowDown to move the focused row among its siblings, Alt + ArrowRight to move it into the folder above, and Alt + ArrowLeft to move it out one level. Every move is announced to screen readers.

`onMove` receives `{ id, targetId, position, fromParentId, toParentId, index }`, where `index` is the position among the new siblings. `moveTreeNode` applies it to your data and keeps untouched branches as they were:

```tsx
"use client";

import { useState } from "react";
import { TreeView, moveTreeNode, type TreeMoveEvent, type TreeNode } from "@/components/ui/tree-view";

export function Documents({ initial }: { initial: TreeNode[] }) {
  const [items, setItems] = useState(initial);

  const onMove = (event: TreeMoveEvent) => {
    const previous = items;
    setItems(moveTreeNode(items, event));
    fetch(`/api/nodes/${event.id}/move`, {
      method: "PATCH",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ parentId: event.toParentId, index: event.index }),
    })
      .then((response) => {
        if (!response.ok) setItems(previous);
      })
      .catch(() => setItems(previous));
  };

  return <TreeView items={items} draggable folderIcons onMove={onMove} labels={{ tree: "Documents" }} />;
}
```

Per node, `draggable: false` pins a row in place and `droppable: false` stops anything from landing inside it. `droppable: true` on a leaf lets it accept drops like a folder.

> Live demo (Tree View): Drag a file into another folder, or focus a row and press Alt + ArrowUp to move it among its siblings. Try it at [Tree View](https://wingo-ui.com/components/tree-view) and install it with `npx wingo-ui@latest add tree-view`.

## Which keyboard shortcuts does a tree view need?

A tree view needs the WAI-ARIA tree keys: arrows to move and open, Home and End, Enter and Space, and type-ahead, all behind one Tab stop. The tree has `role="tree"`, rows are `treeitem`s and open folders hold a `group`. Inside it:

- **ArrowDown / ArrowUp** move between visible rows; **Home / End** go to the first and last; **PageUp / PageDown** jump ten rows.
- **ArrowRight** opens a closed folder or steps into an open one; **ArrowLeft** closes an open folder or jumps to the parent.
- **Enter** opens a file or toggles a folder; **Space** selects, or checks when checkboxes are on.
- **Shift + arrows** extend a multiple selection, **Ctrl + A** (Cmd + A on a Mac) selects every visible row, **Escape** clears it.
- **\*** opens every folder beside the focused one, and typing letters jumps to the next row that starts with them.
- **Shift + F10** or the ContextMenu key opens the row's ⋯ menu.

These follow the WAI-ARIA pattern, which recommends type-ahead for every tree and especially for trees with more than seven root nodes. All strings, including the move announcements, live in `labels` and merge over `DEFAULT_TREE_VIEW_LABELS`, so you can translate the tree without forking it.

## How does it behave on a phone and with big trees?

On a touch screen rows grow to 44px, the whole row is the tap target, a tap on a file runs `onAction`, and the ⋯ button stays visible and opens a bottom sheet.

For big trees, `virtualize="auto"` renders only the rows in view once more than 300 rows are visible. A virtualized tree is flat in the DOM, sets `aria-level`, `aria-setsize` and `aria-posinset` on each row, skips the expand animation and defaults to a 420px scroll box. Set `maxHeight` to choose your own.

## What are the common tree view pitfalls?

- **Duplicate ids.** Selection, expansion, checks and moves all use `id`, so it must be unique across the whole tree, not only among siblings. A development warning names duplicates.
- **Saving folder ids from cascade.** Fully checked folders are part of `checkedKeys`. Store leaves, as in the role editor above, or your backend has to know which ids are folders and what they contained when they were saved.
- **Moving without updating `items`.** The tree is controlled by your data. If `onMove` does not update `items`, the row snaps back.
- **Using a tree for two levels.** A list of sections with a few items each reads better as an [Accordion](https://wingo-ui.com/components/accordion) or a [Sidebar](https://wingo-ui.com/components/sidebar) group.

## When should I use the File Manager instead?

When the tree is the side panel of a documents screen. The [File Manager](https://wingo-ui.com/components/file-manager) takes a flat `items` list of `{ id, kind, name, parentId }` and shows folders and files in a grid or a list, with a folder tree (built on the Tree View) beside them once the manager is 768px wide. It adds uploads with progress, rename in place, Move to, bulk actions and search across every folder. Its `buildFolderTree` turns the flat list into Tree View nodes, the same job as the `toTree` helper above.

> Live demo (File manager): Double click a folder to open it, then drag a file onto another folder or a breadcrumb to move it; right-click a file or use its ⋯ button for Rename and Move to. Try it at [File manager](https://wingo-ui.com/components/file-manager) and install it with `npx wingo-ui@latest add file-manager`.

## How do I add it to my project?

Run `npx wingo-ui@latest add tree-view` (or `file-manager`). The CLI copies the source into `components/ui`, adds the components and hooks it imports, and installs `@dnd-kit/core`, `@tanstack/react-virtual`, `motion` and `lucide-react`. Both are Pro components ([pricing](https://wingo-ui.com/pricing)); the [Checkbox](https://wingo-ui.com/components/checkbox) they use is free. For more single-component deep dives, see the [component guides category](https://wingo-ui.com/blog/category/components) and the [React form components guide](https://wingo-ui.com/blog/react-form-components-guide), and if your tree is really a JSON payload, the [React JSON viewer guide](https://wingo-ui.com/blog/react-json-viewer) covers that case.

## Components in this post

- [Tree View](https://wingo-ui.com/components/tree-view) (Pro): A React tree view for folders, categories or permissions: animated expand, selection, tri-state checkboxes, filter and drag to move. Install: `npx wingo-ui@latest add tree-view`
- [File manager](https://wingo-ui.com/components/file-manager) (Pro): A React file manager for the documents of a company or a record: folders and files in a grid or a list, a folder tree, uploads and bulk actions. Install: `npx wingo-ui@latest add file-manager`

## FAQ

### Does shadcn/ui have a tree view component?

No, as of October 2026. shadcn/ui ships a sidebar block with a collapsible file tree (sidebar-11) built from Collapsible, but no tree component with keyboard navigation, checkboxes or drag and drop. Community registries such as ReUI and mrlightful's shadcn-tree-view, and the Wingo UI Tree View, fill that gap.

### How do I add checkboxes to a React tree view?

Turn on checkboxes and keep checkedKeys in state. With cascade on (the default), checking a folder checks everything under it, and a folder with only some children checked shows a dash, the indeterminate state.

### How do I load tree children lazily in React?

Mark a node with hasChildren and leave out children, then pass a loadChildren function that returns a promise of the child nodes. The Tree View shows a spinner row while it loads, a Retry row if the promise rejects, and caches the result.

### How do I convert a flat list with parentId into a tree?

Build a map from id to node in one pass, then push each node into its parent's children or into the root list. The helper in this post does it in about ten lines, and the File Manager's buildFolderTree does the same for its folders.

### Is the Wingo UI Tree View free?

No. The Tree View and the File Manager are Pro components. The Checkbox and Dropdown Menu they build on are free.

---

Source: https://wingo-ui.com/blog/react-tree-view
