Drag-and-drop sortable representation of hierarchical data for React 19, usable by mouse, touch and keyboard, with virtualized rendering powered by virtua and drag-and-drop by @nosferatu500/react-dnd. Storybook demos cover both basic and advanced scenarios.
This is a maintained fork of react-sortable-tree
by Chris Fritz, carrying its full git history. Upstream's last release was
v2.8.0 in August 2020, targeting React 16; this fork started in June 2021 to add
React 17 support and has been maintained since. The comparison below is against that
last upstream release. The component API is recognisably the same; everything underneath
has been rebuilt.
| original v2.8.0 (Aug 2020) | this fork v7 | |
|---|---|---|
| Maintenance | no releases since 2020 | actively maintained |
| React | 16 | 19 (incl. React Compiler) |
| Components | class components | function components + hooks |
| TypeScript | none bundled — needed @types/react-sortable-tree |
written in TS, .d.ts shipped |
| Accessibility | one aria-label, no keyboard support |
full ARIA tree pattern, keyboard navigation and drag and drop |
| Virtualization | react-virtualized |
virtua |
| Drag and drop | react-dnd 11 |
@nosferatu500/react-dnd 19 — a maintained fork |
| Runtime dependencies | 7 | 4 |
| Module format | CJS + ESM | ESM only |
| Styling | plain CSS | CSS custom properties, @property, @layer |
| Tests | Jest | Vitest, 226 tests |
Migrating from the original? You still import a stylesheet, just from the scoped
name (import '@nosferatu500/react-sortable-tree/style.css'). The main API change is
that SortableTree is a named export rather than the default. Props tied to the old
react-virtualized list no longer exist; virtuaRef exposes the virtual list instead.
The per-version migration notes in CHANGELOG.md cover the rest.
Measured against the original release and against
@minoru/react-dnd-treeview, the
other actively maintained react-dnd tree for React. Same tree, same 600×900 viewport,
same 62px rows, an HTML5 drag-and-drop backend in all three, real Chrome. The harness,
the exact method and its known asymmetries live in benchmark/:
npm run bench:setup # build + pack the fork, install the three workspaces
npm run bench # writes benchmark/results.json and RESULTS.mdThe original is benchmarked on React 16.14, the newest React its peer range allows;
the other two on React 19.2. Note the drag-and-drop layers differ by necessity: this fork
is on @nosferatu500/react-dnd 19 plus its keyboard backend, the other two on upstream
react-dnd 16. Medians of 11 runs after a discarded warm-up, on an Apple M4. A figure is
only bolded as a win where the winner's slowest run still beat the runner-up's fastest
run — results.json keeps the min/max behind every median so that test is reproducible
rather than a judgement call.
| this fork v7.0.0 | react-sortable-tree v2.8.0 | @minoru/react-dnd-treeview v3.5.4 | |
|---|---|---|---|
| JS, minified + gzipped (library alone) | 16.0 kB | 46.5 kB | 52.2 kB |
JS, minified + gzipped (with react-dnd + HTML5 backend) |
30.9 kB | 65.0 kB | 64.5 kB |
| Stylesheet, gzipped | 2.5 kB | 3.2 kB | none (headless) |
| npm packages installed | 8 | 30 | 20 |
node_modules on disk |
4.6 MB | 12.9 MB | 14.6 MB |
| React versions supported | 19 | 16 only | 18, 19 |
Main-thread CPU time. A "group" is 1/10th of the tree, so expanding one at 10,000 nodes reveals 999 rows.
| Nodes | Library | Mount CPU | Expand a group | Scroll top→bottom CPU | DOM elements | Event listeners | JS heap |
|---|---|---|---|---|---|---|---|
| 100 | this fork | 4.8 ms | 3.2 ms | 62.7 ms | 177 | 254 | 3.1 MB |
| 100 | react-sortable-tree | 4.2 ms | 2.0 ms | 54.7 ms | 253 | 199 | 3.4 MB |
| 100 | @minoru | 7.0 ms | 3.3 ms | 11.6 ms | 322 | 993 | 4.1 MB |
| 1,000 | this fork | 4.5 ms | 3.1 ms | 109 ms | 175 | 252 | 3.7 MB |
| 1,000 | react-sortable-tree | 4.1 ms | 2.2 ms | 121 ms | 251 | 197 | 4.0 MB |
| 1,000 | @minoru | 63.7 ms | 23.4 ms | 26.8 ms | 3,022 | 8,193 | 16.2 MB |
| 10,000 | this fork | 6.9 ms | 6.8 ms | 94.4 ms | 175 | 252 | 8.2 MB |
| 10,000 | react-sortable-tree | 9.4 ms | 4.7 ms | 109 ms | 251 | 197 | 6.3 MB |
| 10,000 | @minoru | 2,228 ms | 584 ms | 43.2 ms | 30,022 | 80,193 | 135.8 MB |
All three scrolled at full frame rate. p95 frame time stayed between 8.7 ms and 9.3 ms for every library at every size, and not one run dropped a frame. The scroll column is therefore headroom consumed, not jank observed — see below for why it is the one column the virtualized libraries lose.
This fork and the original are near-indistinguishable on CPU, with one exception. Mount and scroll have overlapping run-to-run ranges at every size, which is why neither is bolded — one library is a fork of the other and both virtualize the same way. But expanding a group at 1,000 nodes now separates: 2.9–3.9 ms for this fork against 1.6–2.3 ms for the original, with no overlap, so the original takes that cell.
Scroll CPU rises from 100 to 1,000 nodes and then flattens (109 ms and 94.4 ms overlap). The 40 scroll steps are a fixed count, so past ~1,000 nodes every step jumps further than one viewport and replaces the whole rendered window — the cost per step saturates rather than continuing to grow with the tree.
Time until rows are actually on screen, which includes waiting for a display frame:
| Nodes | this fork | react-sortable-tree | @minoru/react-dnd-treeview |
|---|---|---|---|
| 100 | 11.8 ms | 4.5 ms | 7.2 ms |
| 1,000 | 12.6 ms | 4.5 ms | 63.1 ms |
| 10,000 | 12.0 ms | 9.1 ms | 2,242 ms |
Where this fork wins. A third of the bytes of either alternative, a third of the install
footprint, and a flat cost curve: mounting 10,000 nodes takes ~7 ms because only 13 rows are
ever in the DOM. Against @minoru at 10,000 nodes that is ~320× less mount CPU, ~86×
cheaper expands, ~171× fewer DOM elements and ~17× less heap. It is also the only one of the
three implementing the ARIA tree pattern — role="treeitem" with
aria-level/aria-setsize/aria-posinset/aria-expanded, a roving tabindex, arrow-key
navigation and keyboard drag and drop. The original exposes rows as react-virtualized grid
cells (role="gridcell" inside role="grid"), @minoru as <li role="listitem">; in
neither are rows focusable.
Where this fork loses. Rows appear about a frame later than the original
(12.6 ms vs 4.5 ms at 1,000 nodes) because virtua measures its viewport from a
ResizeObserver before it can fill it. At 10,000 nodes it holds more heap than the
original (8.2 MB vs 6.3 MB) and keeps a few more event listeners per viewport. Expanding a
group costs about 1 ms more than the original on small trees, which is the price of composing
a keyboard backend over the pointer one. Scrolling costs real CPU — ~94 ms to sweep the whole
tree against @minoru's ~43 ms — because rows are re-rendered as they come into view instead
of already existing. That is the virtualization trade in one number: spend CPU while
scrolling, in exchange for a DOM and a heap that stop growing with the tree. Neither choice
dropped a frame here. And it is React 19 only: @minoru still supports React 18.
When to pick @minoru/react-dnd-treeview instead. It is the right call for small
trees where you want full markup control: it ships no CSS, animates expand/collapse with
framer-motion, bundles multi-backend touch support, and supports React 18. Scrolling is
cheaper because nothing re-renders. The cost is that everything is in the DOM — at 10,000
nodes that is 30,022 elements, 80,193 listeners, 136 MB of heap and a 2.2 second mount
that blocks the main thread. Even 1,000 nodes take 64 ms to mount, past the frame budget.
It also has no built-in search, no ARIA tree semantics and no keyboard navigation.
The original v2.8.0 is a reasonable choice only if you are pinned to React 16. As the
table shows it is still a match on runtime — this is a fork of it — but it was last
published in August 2020, ships no TypeScript types, and pulls in 30 packages. It does
not run on React 19 at all: the harness mounts it there too and it throws
A React Element from an older version of React was rendered.
Drag and drop is pointer-only in all three. None of them supports keyboard-driven reordering; this fork adds keyboard navigation and focus management, not keyboard dragging.
Install the package together with its peer dependencies:
npm install @nosferatu500/react-sortable-tree \
@nosferatu500/react-dnd @nosferatu500/react-dnd-html5-backend
# or
yarn add @nosferatu500/react-sortable-tree \
@nosferatu500/react-dnd @nosferatu500/react-dnd-html5-backendUpgrading from v6? The peers changed packages:
react-dndandreact-dnd-html5-backendbecame@nosferatu500/react-dndand@nosferatu500/react-dnd-html5-backend. Upstreamreact-dndhas had no release since 2022 and no declared React 19 support. If you never importedreact-dndyourself, the install line above is the whole migration; see CHANGELOG.md if you did.
Node >= 22.12 is required, because those packages are ESM-only and 22.12 is the first
release that can require() an ES module.
Then import the stylesheet once, anywhere in your app:
import '@nosferatu500/react-sortable-tree/style.css'The bundle is ESM-only. Styles ship as a real stylesheet rather than being injected at runtime, so they work with a strict Content-Security-Policy, are present in server-rendered HTML, and are minified and cached by your own build.
import { useState } from 'react'
import { SortableTree, TreeItem } from '@nosferatu500/react-sortable-tree'
import '@nosferatu500/react-sortable-tree/style.css'
const initialData: TreeItem[] = [
{ title: 'Chicken', children: [{ title: 'Egg' }] },
{ title: 'Fish', children: [{ title: 'Fingerling' }] },
]
export function ExampleTree() {
const [treeData, setTreeData] = useState(initialData)
return (
<div style={{ height: 400 }}>
<SortableTree treeData={treeData} onChange={setTreeData} />
</div>
)
}Already have a surrounding drag-and-drop context? Use the context-less export, and wrap
your own backend with withTreeKeyboard so keyboard dragging still works:
import { DndProvider } from '@nosferatu500/react-dnd'
import { HTML5Backend } from '@nosferatu500/react-dnd-html5-backend'
import {
SortableTreeWithoutDndContext,
withTreeKeyboard,
} from '@nosferatu500/react-sortable-tree'
// Once, at module scope — a new backend identity rebuilds the whole manager.
const backend = withTreeKeyboard(HTML5Backend)
;<DndProvider backend={backend}>
<SortableTreeWithoutDndContext treeData={treeData} onChange={setTreeData} />
</DndProvider>SortableTree does this for you. A bare pointer backend has no keyboard gesture at all,
and losing it is silent, so this matters whenever you supply the provider — including when
you swap in TouchBackend.
You do not have to choose one pointer backend. composeBackends runs several at once, so
mouse and touch both work with no feature detection between them:
import { composeBackends } from '@nosferatu500/dnd-core'
const backend = withTreeKeyboard(composeBackends(HTML5Backend, TouchBackend))withTreeKeyboard flattens a composite rather than nesting one, so this stays a single
backend as far as the provider is concerned. Only compose backends that answer different
gestures — HTML5Backend listens for dragstart and TouchBackend for touchstart, which
do not overlap unless you turn on TouchBackend's enableMouseEvents.
All props are typed in ReactSortableTreeProps (see src/react-sortable-tree.tsx).
| Prop | Type | Description |
|---|---|---|
treeData |
TreeItem[] |
Array of tree nodes with { title?, subtitle?, expanded?, children?, ...custom } |
onChange |
(treeData: TreeItem[]) => void |
Called on every tree data change |
title and subtitle take a value or a function of the node, which is called with
{ node, path, treeIndex } when the row renders:
const treeData: TreeItem[] = [
{ title: ({ treeIndex }) => <b>{`#${treeIndex} Chicken`}</b> },
]A node's children can also be a function — see Lazy children.
| Prop | Type | Default | Description |
|---|---|---|---|
rowHeight |
number | ((treeIndex, node, path) => number) |
62 |
Height of each row in pixels |
rowDirection |
'ltr' | 'rtl' |
'ltr' |
Layout direction |
scaffoldBlockPxWidth |
number |
44 |
Width of indent per level |
style |
CSSProperties |
- | Styles for the outer container |
innerStyle |
CSSProperties |
- | Styles for the virtual list |
className |
string |
- | Class name for the outer container |
| Prop | Type | Description |
|---|---|---|
theme |
ThemeProps |
Theme object (see Theming section) |
nodeContentRenderer |
ComponentType |
Custom component for node content |
treeNodeRenderer |
ComponentType |
Custom component for the entire tree row |
placeholderRenderer |
ComponentType |
Custom component for empty tree state |
| Prop | Type | Default | Description |
|---|---|---|---|
canDrag |
boolean | ((params) => boolean) |
true |
Whether nodes can be dragged |
canDrop |
(params) => boolean |
- | Validate if a drop is allowed |
canNodeHaveChildren |
(node) => boolean |
() => true |
Whether a node can have children |
maxDepth |
number |
- | Maximum nesting depth |
shouldCopyOnOutsideDrop |
boolean | ((params) => boolean) |
false |
Copy node when dropped outside |
dndType |
string |
- | Custom drag type for multi-tree setups |
onMoveNode |
(params) => void |
- | Called after a node is moved |
onDrop |
(params, signal) => Promise|void |
- | Persists a move, and is awaited |
onDragStateChanged |
(params) => void |
- | Called when drag state changes |
onChange and onMoveNode both fire the moment a move commits and cannot fail: they say
this happened. onDrop says make this stick, and it is awaited, so returning a
promise gives the tree the three states a save really has instead of one:
<SortableTree
treeData={treeData}
onChange={setTreeData}
onDrop={async ({ treeData }, signal) => {
await fetch('/api/tree', {
method: 'PUT',
body: JSON.stringify(treeData),
signal,
})
}}
/>- While it is pending the move is already committed, so the tree is not frozen under the
cursor. The moved row gets
aria-busyand therst__rowSettlingclass, and a customnodeContentRendererreceivesisSettling. - If it resolves, the move is announced to screen readers as done — only now, rather than optimistically at drop time.
- If it rejects, the tree reverts to the data from before the drop and emits
onChangewith it. A consumer that only listens toonChangestill ends up consistent, and a screen reader hears that the move failed instead of silence.
signal aborts once the drop can no longer affect anything — today, when a new drag starts.
Forward it to fetch; an AbortError after it fires is treated as the tree's own doing and
neither reverts nor announces. The rejection reason also reaches monitor.getDropError() and
the environment's uncaught-error handling, so a failure is never silent even with nothing
rendering it.
Omitting onDrop keeps drops entirely synchronous, exactly as before. See the
Advanced/AsyncDrop story.
| Prop | Type | Description |
|---|---|---|
searchQuery |
string |
Search query string |
searchMethod |
(params) => boolean |
Custom search matching function |
searchFocusOffset |
number |
Index of the focused match |
searchFinishCallback |
(matches) => void |
Called when search completes |
onlyExpandSearchedNodes |
boolean |
Collapse non-matching paths |
| Prop | Type | Description |
|---|---|---|
generateNodeProps |
(params) => object |
Add custom props to each node |
getNodeKey |
(node) => string | number |
Builds the segments of every path |
onVisibilityToggle |
(params) => void |
Called when node expands/collapses |
loadCollapsedLazyChildren |
boolean |
Load lazy children before expanding |
virtuaRef |
RefObject<VListHandle> |
Direct access to the virtual list |
dragDropManager |
object |
External drag-and-drop manager |
getNodeKey builds the segments of the path arrays you get back — in onMoveNode's
prevPath/nextPath, in onVisibilityToggle, and in every helper that takes a path. The
default is ({ treeIndex }) => treeIndex, so those paths are positions: they are only valid
against the tree they came from, and a stored path points somewhere else after a reorder.
Return your own stable id if you keep paths around, diff trees, or persist selection:
<SortableTree
treeData={treeData}
onChange={setTreeData}
getNodeKey={({ node }) => node.id}
/>You do not need it to keep row state (an open editor, a checkbox, focus) attached to the
right row while rows move — rows are reconciled by node identity when you leave getNodeKey
unset. The one case that benefits: if you replace treeData with freshly built objects on
every change, supply getNodeKey so rows can still be matched across the replacement.
| Prop | Type | Default | Description |
|---|---|---|---|
aria-label |
string |
- | Accessible name for the tree |
aria-labelledby |
string |
- | Id of an element naming the tree |
keyboardNavigation |
boolean |
true |
Set to false to opt out of built-in arrow keys |
announcements |
object |
- | Replaces the strings the tree speaks |
The tree implements the WAI-ARIA tree view pattern. Give it an accessible name — everything else is automatic:
<SortableTree
aria-label="Project files"
treeData={treeData}
onChange={setTreeData}
/>Rows are exposed as role="treeitem" with aria-level, aria-setsize,
aria-posinset, and aria-expanded (the last only on nodes that actually have
children). Because the list is virtualized the DOM is flat, so depth and position are
stated explicitly rather than implied by nesting.
One row is in the tab sequence at a time — a roving tabindex — so tabbing into the tree lands on the active row and tabbing again leaves it.
| Key | Action |
|---|---|
| ↓ / ↑ | Move to the next / previous visible row |
| → | Expand a collapsed node, or move to its first child |
| ← | Collapse an expanded node, or move to its parent |
| Home / End | Move to the first / last visible row |
| Enter / Space | Toggle the focused node |
← and → swap roles when rowDirection="rtl". Keys the tree does
not handle are left alone, and keystrokes originating in an input, textarea,
select, or contenteditable inside a row are never intercepted — so inline renaming
keeps working.
Expanding or collapsing via the keyboard goes through the same onChange and
onVisibilityToggle callbacks as clicking the toggle.
Pass keyboardNavigation={false} to handle the arrow keys yourself; the ARIA roles and
the roving tabindex stay in place.
Nodes can be reordered and re-nested without a pointer. Tab from a row to its drag handle, then:
| Key | Action |
|---|---|
| Space / Enter | Pick the row up — press again to drop it |
| ↓ / ↑ | Choose which row it lands next to |
| → / ← | Nest it one level deeper / shallower |
| Esc | Cancel, leaving the tree as it was |
Up and down choose where the row lands; left and right choose how deeply it nests —
the same split as a pointer drag, where horizontal movement is the only thing that nests a
node. They mirror under rowDirection="rtl".
Focus stays on the dragged row for the whole interaction and only the hover moves, so
isOver and canDrop behave exactly as they do under the mouse and existing highlight
styles keep working. A polite live region narrates each step — pick-up, each move, depth
changes (including a depth the surrounding rows refuse), and where the node finally landed.
Drops go through the same onChange and onMoveNode callbacks as a mouse drag.
With an async onDrop the landing message becomes
three — "moving to…", then either "moved to…" or "could not be moved…". Announcing the move as
done at drop time and then saying nothing when the save fails is a lie a screen-reader user
cannot catch: the rows snap back in silence.
The drag handle shares its row's tab stop, so the tree stays a single tab stop however many
rows are on screen. Custom nodeContentRenderers should do the same: put the
isActiveRow prop on whatever you connect as the drag source, or every visible row becomes
its own tab stop.
<div ref={connectDragSource} tabIndex={isActiveRow ? 0 : -1} />Every string a screen reader hears can be replaced. They come from two places, and both halves are needed to hear no English at all — the split is where the knowledge is. The backend narrates the drag it can see (pick-up, movement, drop, cancellation) and knows nothing about what a row means. The tree knows the rows are a hierarchy, and says where a node landed and what depth a key produced — but not which row the backend is hovering.
The backend's strings go through withTreeKeyboard, which forwards the keyboard backend's
announcements and describeNode:
// Once, at module scope — a new backend identity rebuilds the whole manager.
const backend = withTreeKeyboard(HTML5Backend, {
announcements: {
instructions: 'Appuyez sur Espace pour saisir cet élément.',
pickUp: ({ source }) => `${source} saisi.`,
move: ({ target, targetIndex, targetCount }) =>
`Au-dessus de ${target}, ${targetIndex} sur ${targetCount}.`,
},
})The tree's own go on the announcements prop, since they are per-tree rather than
per-backend:
<SortableTreeWithoutDndContext
announcements={{
moved: ({ node, parentNode, depth }) =>
`${node.title} déplacé à la profondeur ${depth}${parentNode ? ` sous ${parentNode.title}` : ''}.`,
depth: ({ depth, changed }) =>
`Profondeur ${depth}${changed ? '' : ', inchangée'}.`,
// `moving` and `moveFailed` cover the two extra states an async `onDrop` adds.
}}
/>Each key falls back on its own, so overriding moved alone leaves the others in English
rather than silencing them. defaultTreeAnnouncements is exported if you would rather wrap a
default than replace it. Only announcements and describeNode are forwarded through
withTreeKeyboard: getNextTarget and onNavigate are what make the backend tree-shaped,
and replacing either would cost you depth control or the vertical arrows — call withKeyboard
directly if that is really what you want.
A node's children can be a function instead of an array. It is called when the node is
expanded — or immediately, for every such node, if you pass
loadCollapsedLazyChildren — and the node shows a loading indicator until its children
arrive. Return the children, or a promise for them:
const treeData: TreeItem[] = [
{
title: 'Remote folder',
children: async ({ node }) => {
const res = await fetch(`/api/children?id=${String(node.id)}`)
return (await res.json()) as TreeItem[]
},
},
]The loaded children are written into the tree through onChange, like every other
change, so they end up in your own state.
Two things to know: the update is applied to the tree as it was when the loader was
called, and a node whose children is still a function may be asked to load again on a
later tree change. Loaders that fetch should therefore be idempotent or cached.
Coming from v5? The done(children) callback is gone — return the children instead:
// v5
children: ({ done }) => {
fetchChildren().then(done)
}
// v6
children: () => fetchChildren()The component supports theming through CSS variables, the theme prop, and custom renderers.
Override these CSS variables on the .rst__tree class or a parent element:
.my-custom-theme .rst__tree {
--rst-row-height: 62px;
--rst-block-width: 44px;
--rst-handle-width: 44px;
--rst-line-color: #000;
--rst-line-highlight: #36c2f6;
--rst-line-highlight-arrow: white;
--rst-primary-color: #36c2f6;
--rst-focus-color: #fc6421;
--rst-match-color: #0080ff;
--rst-bg-landing: lightblue;
--rst-bg-cancel: #e6a8ad;
--rst-text-color: #333;
--rst-icon-color: #6db3f2;
--rst-button-bg: #fff;
--rst-button-border: #989898;
}Every variable is declared with @property, so an invalid value falls back to the
default instead of collapsing the layout.
All of the component's rules live in a rst cascade layer. Unlayered CSS always beats
layered CSS regardless of specificity, so your own rules win without !important or
selector escalation:
/* No .rst__tree prefix, no !important — this just wins. */
.rst__row {
border-radius: 8px;
}If you use cascade layers yourself, order rst explicitly to place it relative to your
own layers:
@layer rst, components, utilities;The theme prop accepts an object with these properties:
type ThemeProps = {
style?: React.CSSProperties
innerStyle?: React.CSSProperties
scaffoldBlockPxWidth?: number
treeNodeRenderer?: React.ComponentType
nodeContentRenderer?: React.ComponentType
placeholderRenderer?: React.ComponentType
dndType?: string
}Theme values are merged with component props, with direct props taking precedence.
The library includes a File Explorer theme example in the Storybook demos:
import { SortableTree } from '@nosferatu500/react-sortable-tree'
import {
fileExplorerTheme,
FILE_EXPLORER_THEME_CLASS,
} from './themes/file-explorer'
function FileTree() {
const [treeData, setTreeData] = useState([
{
title: 'src',
isDirectory: true,
expanded: true,
children: [{ title: 'index.ts' }, { title: 'App.tsx' }],
},
{ title: 'package.json' },
])
return (
<div className={FILE_EXPLORER_THEME_CLASS}>
<SortableTree
treeData={treeData}
onChange={setTreeData}
theme={fileExplorerTheme}
rowHeight={28}
// Only folders can have children
canNodeHaveChildren={(node) => node.isDirectory === true}
// Only allow dropping into folders
canDrop={({ nextParent }) =>
!nextParent || nextParent.isDirectory === true
}
/>
</div>
)
}For dark mode, add the rst__file-explorer-dark class to the wrapper.
To create a custom theme:
- Create a custom
nodeContentRenderercomponent (seesrc/node-renderer-default.tsxfor reference) - Add CSS styles with your theme class
- Export a theme object:
export const myTheme = {
nodeContentRenderer: MyCustomNodeRenderer,
scaffoldBlockPxWidth: 24,
}Utilities exported from the package:
addNodeUnderParent({ treeData, newNode, parentKey, getNodeKey, expandParent?, addAsFirstChild? })- Add a node under a parentinsertNode({ treeData, newNode, depth, minimumTreeIndex, getNodeKey, expandParent? })- Insert a node at a specific positionremoveNode({ treeData, path, getNodeKey })- Remove a node by pathremoveNodeAtPath({ treeData, path, getNodeKey })- Remove a node at exact pathchangeNodeAtPath({ treeData, path, newNode, getNodeKey })- Update a node at path
getNodeAtPath({ treeData, path, getNodeKey })- Get node at pathgetDescendantCount({ node })- Count all descendantsgetDepth(node)- Get nesting depth of a nodeisDescendant(older, younger)- Check parent-child relationshipgetVisibleNodeCount({ treeData })- Count visible (expanded) nodes
walk({ treeData, getNodeKey, callback, ignoreCollapsed? })- Walk tree depth-firstmap({ treeData, getNodeKey, callback, ignoreCollapsed? })- Transform all nodestoggleExpandedForAll({ treeData, expanded })- Expand or collapse all nodesfind({ treeData, getNodeKey, searchQuery, searchMethod, expandAllMatchPaths? })- Search with path expansion
getFlatDataFromTree({ treeData, getNodeKey, ignoreCollapsed? })- Convert to flat arraygetTreeFromFlatData({ flatData, getKey, getParentKey, rootKey? })- Convert from flat array
defaultGetNodeKey({ treeIndex })- Default key generator (uses index)defaultSearchMethod({ node, searchQuery })- Default search (matches title)
MIT