react-dendro
v0.1.0
Published
High-performance React Tree component with virtualization and drag-and-drop support
Maintainers
Readme
react-dendro
High-performance React Tree component with virtualization and drag-and-drop support.
Features
- Virtualization — Efficiently renders large trees with thousands of nodes
- Drag & Drop — Built-in support for reordering and moving nodes (including cross-tree)
- TypeScript — Full type safety with generics support
- Customizable — Render props for complete control over node appearance
- Accessible — Keyboard navigation support
- Zustand Store — External state management for data, selection, focus, and open states
- Global API — Access tree methods from anywhere via
getTreeApi()
Installation
npm install react-dendro
# or
yarn add react-dendro
# or
pnpm add react-dendroTable of Contents
- Quick Start
- Architecture
- TreeStoreProvider
- Tree Component
- Working with Data
- Node Rendering
- Performance Optimization
- NodeApi
- TreeApi
- Drag & Drop
- Search & Filtering
- API Reference
Quick Start
import { Tree, TreeStoreProvider } from 'react-dendro'
interface FileNode {
id: string
name: string
children?: FileNode[]
}
const data: FileNode[] = [
{
id: '1',
name: 'Documents',
children: [
{ id: '2', name: 'report.pdf' },
{ id: '3', name: 'notes.txt' },
],
},
]
function App() {
return (
<TreeStoreProvider names={['file-tree']}>
<Tree<FileNode>
name="file-tree"
width="100%"
height={400}
>
{({ node, style }) => (
<div style={style}>
{node.isInternal && (node.isOpen ? '▼ ' : '▶ ')}
{node.data.name}
</div>
)}
</Tree>
</TreeStoreProvider>
)
}Architecture
react-dendro uses a Zustand-based architecture where tree state is managed externally:
┌────────────────────────────────────────────┐
│ TreeStoreProvider │
│ (Creates Zustand stores for each tree) │
│ │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ Tree Store │ │ Tree Store │ │
│ │ "devices-tree" │ │ "users-tree" │ │
│ │ │ │ │ │
│ │ - data │ │ - data │ │
│ │ - selection │ │ - selection │ │
│ │ - focus │ │ - focus │ │
│ │ - open states │ │ - open states │ │
│ └────────┬────────┘ └────────┬────────┘ │
│ │ │ │
│ ┌────────▼────────┐ ┌────────▼────────┐ │
│ │ Tree Component │ │ Tree Component │ │
│ │ name="devices" │ │ name="users" │ │
│ └─────────────────┘ └─────────────────┘ │
└────────────────────────────────────────────┘TreeStoreProvider
Wrap your application (or the part that uses trees) with TreeStoreProvider:
import { TreeStoreProvider } from 'react-dendro'
// Define tree names as constants for type safety
const TREE_NAMES = {
DEVICES: 'devices-tree',
USERS: 'users-tree',
} as const
function App() {
return (
<TreeStoreProvider names={Object.values(TREE_NAMES)}>
{/* Your app components */}
</TreeStoreProvider>
)
}Tree Component
Basic Props
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| name | string | required | Unique tree identifier |
| loading | boolean | false | Shows loading indicator |
| width | number \| string | "100%" | Tree width |
| height | number | - | Tree height in pixels |
| rowHeight | number | 32 | Row height in pixels |
| indent | number | 20 | Indentation per level |
| overscanCount | number | 5 | Extra rows for smooth scrolling |
| openByDefault | boolean | false | Expand all nodes initially |
| className | string | - | CSS class for tree container |
| rowClassName | string | - | CSS class for rows |
Data Accessors
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| idAccessor | string \| (d: T) => string | "id" | Get node ID |
| childrenAccessor | string \| (d: T) => T[] \| null | "children" | Get children |
| sortAccessor | string \| (d: T) => string \| number | - | Sort key for insertions |
Event Handlers
| Prop | Type | Description |
|------|------|-------------|
| onActivate | (node: NodeApi<T>) => void | Node double-click/Enter |
| onSelect | (nodes: NodeApi<T>[]) => void | Selection change |
| onFocus | (node: NodeApi<T>) => void | Node focus change |
| onToggle | (id: string) => void | Node expand/collapse |
| onMove | MoveHandler<T> | Node moved via drag & drop |
| onCreate | CreateHandler<T> | New node created |
| onDelete | DeleteHandler<T> | Node deleted |
| onScroll | (event: UIEvent) => void | Tree scroll |
| onContextMenu | MouseEventHandler | Right-click on tree |
Renderers
| Prop | Type | Description |
|------|------|-------------|
| children | ElementType<NodeRendererProps<T>> | Node content renderer |
| renderRow | ElementType<RowRendererProps<T>> | Row wrapper renderer |
| renderDragPreview | ElementType<DragPreviewProps> | Drag preview renderer |
| renderCursor | ElementType<CursorProps> | Drop cursor renderer |
| renderContainer | ElementType | Tree container wrapper |
| renderLoader | ElementType | Loading indicator |
Working with Data
Setting Initial Data
Data is set via the tree store, not props. This allows external updates:
import { useTreeStore } from 'react-dendro'
import { useEffect } from 'react'
function DeviceTreePanel() {
const { data: treeData, isLoading } = useQuery(['devices'], fetchDevices)
// Get the store for this tree
const deviceStore = useTreeStore('devices-tree')
const setData = deviceStore(state => state.setData)
// Update tree data when it changes
useEffect(() => {
if (treeData) {
setData(treeData)
}
}, [treeData, setData])
return (
<Tree name="devices-tree" loading={isLoading} height={500}>
{/* ... */}
</Tree>
)
}CRUD Operations
The store provides methods for manipulating data:
const store = useTreeStore('devices-tree')
// Insert a single node
const insertNode = store(state => state.insertNode)
insertNode('parent-id', { id: 'new-1', name: 'New Node' })
// Insert multiple nodes
const insertNodes = store(state => state.insertNodes)
insertNodes([
{ parentId: 'parent-1', node: { id: 'new-1', name: 'Node 1' } },
{ parentId: 'parent-2', node: { id: 'new-2', name: 'Node 2' } },
])
// Update a node
const updateNode = store(state => state.updateNode)
updateNode('node-id', { name: 'Updated Name' })
// Update multiple nodes
const updateNodes = store(state => state.updateNodes)
updateNodes([
{ id: 'node-1', patch: { name: 'Name 1' } },
{ id: 'node-2', patch: { name: 'Name 2' } },
])
// Delete a node
const deleteNode = store(state => state.deleteNode)
deleteNode('node-id')
// Delete multiple nodes
const deleteNodes = store(state => state.deleteNodes)
deleteNodes(['node-1', 'node-2', 'node-3'])
// Get node by ID
const getNodeById = store(state => state.getNodeById)
const node = getNodeById('node-id')Opening Nodes Programmatically
const store = useTreeStore('devices-tree')
const openNode = store(state => state.nodes_open__open)
// Open a specific node
openNode('node-id', false) // false = not in filtered modeNode Rendering
The children prop receives render props for each node:
type NodeRendererProps<T> = {
style: CSSProperties // Positioning styles with indentation
node: NodeApi<T> // Node API with data and methods
tree: TreeApi<T> // Tree API
dragHandle?: (el: HTMLDivElement | null) => void // Drag handle ref
preview?: boolean // true if rendered in drag preview
}Basic Node Renderer
<Tree name="my-tree" height={400}>
{({ node, style, dragHandle }) => (
<div ref={dragHandle} style={style}>
{node.isInternal && (
<button onClick={() => node.toggle()}>
{node.isOpen ? '▼' : '▶'}
</button>
)}
<span>{node.data.name}</span>
{node.isSelected && ' ✓'}
</div>
)}
</Tree>Performance Optimization
For maximum performance with large trees and complex node components, follow these best practices:
1. Create a Memoized TreeItem Component
Extract node rendering into a separate memoized component:
import { memo } from 'react'
interface TreeItemProps {
// Layout
rowHeight: number
style: React.CSSProperties
dragHandle?: (el: HTMLDivElement | null) => void
// State flags (primitives only!)
isFocusSwitching: boolean
isSelectionSwitching: boolean
isSelected: boolean
willReceiveDrop: boolean
isDragging: boolean
isInDragSelection: boolean
level: number
isLeaf: boolean
isOpen: boolean
// Bound methods
toggle: () => void
select: () => void
selectMulti: () => void
selectContiguous: () => void
deselect: () => void
delete: () => void
// Data (primitives only!)
id: string
name: string
type: string
// ... other primitive data fields
}
const TreeItem = memo((props: TreeItemProps) => {
const {
rowHeight,
style,
isFocusSwitching,
isSelected,
level,
isLeaf,
isOpen,
toggle,
select,
dragHandle,
id,
name,
type,
} = props
return (
<div
ref={dragHandle}
style={{ height: rowHeight, ...style }}
onClick={select}
>
{!isLeaf && (
<button onClick={(e) => { e.stopPropagation(); toggle(); }}>
{isOpen ? '▼' : '▶'}
</button>
)}
<span style={{ marginLeft: level * 20 }}>{name}</span>
{isSelected && ' ✓'}
</div>
)
}, areEqualTreeItemProps)
// Custom comparison function for optimal re-renders
const areEqualTreeItemProps = (prev: TreeItemProps, next: TreeItemProps) => {
return (
prev.rowHeight === next.rowHeight &&
prev.isFocusSwitching === next.isFocusSwitching &&
prev.isSelectionSwitching === next.isSelectionSwitching &&
prev.isSelected === next.isSelected &&
prev.willReceiveDrop === next.willReceiveDrop &&
prev.isDragging === next.isDragging &&
prev.isInDragSelection === next.isInDragSelection &&
prev.level === next.level &&
prev.isLeaf === next.isLeaf &&
prev.isOpen === next.isOpen &&
prev.id === next.id &&
prev.name === next.name &&
prev.type === next.type
)
}2. Pass Primitives and Bound Methods
CRITICAL: Pass only primitive values to your TreeItem, and use .bind(node) for methods:
<Tree<MyNode> name="my-tree" height={500}>
{({ node, style, dragHandle }) => (
<TreeItem
rowHeight={node.rowHeight}
style={style}
dragHandle={dragHandle}
// State flags - these are primitive booleans
isFocusSwitching={node.isFocusSwitching}
isSelectionSwitching={node.isSelectionSwitching}
isSelected={node.state.isSelected}
willReceiveDrop={node.willReceiveDrop}
isDragging={node.isDragging}
isInDragSelection={node.isInDragSelection}
level={node.level}
isLeaf={node.isLeaf}
isOpen={node.isOpen}
// IMPORTANT: Use .bind(node) for methods!
// This creates stable function references
toggle={node.toggle.bind(node)}
select={node.select.bind(node)}
selectMulti={node.selectMulti.bind(node)}
selectContiguous={node.selectContiguous.bind(node)}
delete={node.delete.bind(node)}
deselect={node.deselect.bind(node)}
// Data - extract primitives from node.data
id={node.data.id}
name={node.data.name}
type={node.data.type}
/>
)}
</Tree>Why .bind(node)?
Without .bind(node), arrow functions like () => node.toggle() would create new function references on every render, defeating memoization.
3. Use isFocusSwitching and isSelectionSwitching
When users rapidly navigate the tree (holding arrow keys) or select many nodes, isFocusSwitching and isSelectionSwitching become true. Use these to simplify rendering during batch operations:
const TreeItem = memo((props: TreeItemProps) => {
const { isFocusSwitching, isSelectionSwitching, name } = props
// Skip rendering expensive elements during rapid operations
if (isFocusSwitching || isSelectionSwitching) {
return (
<div style={props.style}>
<span>{name}</span>
</div>
)
}
// Full render with all features
return (
<div style={props.style}>
<Checkbox checked={props.isSelected} />
<ExpensiveIcon type={props.type} />
<span>{name}</span>
<Tooltip content={props.description}>
<InfoIcon />
</Tooltip>
<DeleteButton onClick={props.delete} />
</div>
)
})When are these flags true?
isFocusSwitching— Active for ~100ms after rapid focus changes (arrow key navigation)isSelectionSwitching— Active for ~500ms after rapid selection changes (Shift+Click ranges)
4. Full Production Example
import { memo, useCallback, useEffect } from 'react'
import { Tree, useTreeStore, type NodeApi } from 'react-dendro'
// Types
interface DeviceNode {
id: string
name: string
type: 'folder' | 'device'
status?: 'online' | 'offline'
}
interface TreeItemProps {
rowHeight: number
style: React.CSSProperties
dragHandle?: (el: HTMLDivElement | null) => void
isFocusSwitching: boolean
isSelected: boolean
willReceiveDrop: boolean
isDragging: boolean
isInDragSelection: boolean
level: number
isLeaf: boolean
isOpen: boolean
toggle: () => void
select: () => void
selectMulti: () => void
selectContiguous: () => void
deselect: () => void
delete: () => void
id: string
name: string
type: string
status?: string
}
// Memoized comparison
const areEqual = (prev: TreeItemProps, next: TreeItemProps) => (
prev.rowHeight === next.rowHeight &&
prev.isFocusSwitching === next.isFocusSwitching &&
prev.isSelected === next.isSelected &&
prev.willReceiveDrop === next.willReceiveDrop &&
prev.isDragging === next.isDragging &&
prev.isInDragSelection === next.isInDragSelection &&
prev.level === next.level &&
prev.isLeaf === next.isLeaf &&
prev.isOpen === next.isOpen &&
prev.id === next.id &&
prev.name === next.name &&
prev.type === next.type &&
prev.status === next.status
)
// Memoized TreeItem
const TreeItem = memo((props: TreeItemProps) => {
const {
rowHeight, style, dragHandle,
isFocusSwitching, isSelected, willReceiveDrop,
isDragging, isInDragSelection,
level, isLeaf, isOpen,
toggle, select, selectMulti, selectContiguous,
id, name, type, status,
} = props
const handleClick = (e: React.MouseEvent) => {
e.stopPropagation()
if (e.shiftKey) {
selectContiguous()
} else if (e.ctrlKey || e.metaKey) {
isSelected ? props.deselect() : selectMulti()
} else {
select()
}
}
const handleDoubleClick = (e: React.MouseEvent) => {
e.stopPropagation()
if (!isLeaf) toggle()
}
return (
<div
ref={dragHandle}
style={{ height: rowHeight, ...style }}
onClick={handleClick}
onDoubleClick={handleDoubleClick}
className={`
tree-item
${isSelected ? 'selected' : ''}
${willReceiveDrop ? 'drop-target' : ''}
${isDragging ? 'dragging' : ''}
`}
>
{/* Toggle button */}
{!isLeaf && (
<button onClick={(e) => { e.stopPropagation(); toggle(); }}>
{isOpen ? '▼' : '▶'}
</button>
)}
{/* Checkbox - skip during rapid operations */}
{!isFocusSwitching && (
<input
type="checkbox"
checked={isSelected}
onChange={() => isSelected ? props.deselect() : selectMulti()}
onClick={(e) => e.stopPropagation()}
/>
)}
{/* Content */}
<span className="node-name">{name}</span>
{/* Status indicator - skip during rapid operations */}
{!isFocusSwitching && status && (
<span className={`status ${status}`} />
)}
</div>
)
}, areEqual)
// Main component
function DeviceTree() {
const { data, isLoading } = useQuery(['devices'], fetchDevices)
const store = useTreeStore('devices-tree')
const setData = store(state => state.setData)
useEffect(() => {
if (data) setData(data)
}, [data, setData])
const handleDelete = useCallback(async (args: {
ids: string[]
nodes: NodeApi<DeviceNode>[]
}) => {
await deleteDevices(args.ids)
}, [])
return (
<Tree<DeviceNode>
name="devices-tree"
loading={isLoading}
height={600}
width="100%"
rowHeight={32}
indent={20}
overscanCount={20}
onDelete={handleDelete}
canDragNode={(node) => node.level !== 0}
canSelectNode={(node) => node.level !== 0}
className="device-tree"
rowClassName="device-tree-row"
>
{({ node, style, dragHandle }) => (
<TreeItem
rowHeight={node.rowHeight}
style={style}
dragHandle={dragHandle}
isFocusSwitching={node.isFocusSwitching}
isSelected={node.state.isSelected}
willReceiveDrop={node.willReceiveDrop}
isDragging={node.isDragging}
isInDragSelection={node.isInDragSelection}
level={node.level}
isLeaf={node.isLeaf}
isOpen={node.isOpen}
toggle={node.toggle.bind(node)}
select={node.select.bind(node)}
selectMulti={node.selectMulti.bind(node)}
selectContiguous={node.selectContiguous.bind(node)}
delete={node.delete.bind(node)}
deselect={node.deselect.bind(node)}
id={node.data.id}
name={node.data.name}
type={node.data.type}
status={node.data.status}
/>
)}
</Tree>
)
}NodeApi
NodeApi provides access to individual node data and methods:
Properties
| Property | Type | Description |
|----------|------|-------------|
| id | string | Node identifier |
| data | T | Node data object |
| level | number | Nesting level (0 = root) |
| parent | NodeApi<T> \| null | Parent node |
| children | NodeApi<T>[] \| null | Child nodes (null if leaf) |
| rowIndex | number \| null | Index in visible list |
| rowHeight | number | Row height in pixels |
| indent | number | Indentation in pixels |
State Properties
| Property | Type | Description |
|----------|------|-------------|
| isRoot | boolean | Is root node |
| isLeaf | boolean | Has no children |
| isInternal | boolean | Has children |
| isOpen | boolean | Is expanded |
| isClosed | boolean | Is collapsed |
| isSelected | boolean | Is selected |
| isFocused | boolean | Has focus |
| isDragging | boolean | Being dragged |
| isInDragSelection | boolean | In drag selection |
| willReceiveDrop | boolean | Drop target |
| isFocusSwitching | boolean | Rapid focus changes happening |
| isSelectionSwitching | boolean | Rapid selection changes happening |
Methods
| Method | Description |
|--------|-------------|
| toggle() | Toggle expand/collapse |
| open() | Expand node |
| close() | Collapse node |
| openParents() | Expand all ancestors |
| select() | Select only this node |
| selectMulti() | Add to selection |
| selectContiguous() | Select range from anchor |
| deselect() | Remove from selection |
| focus() | Focus this node |
| activate() | Trigger onActivate |
| delete() | Delete this node |
| handleClick(e) | Standard click handler |
Navigation Properties
| Property | Type | Description |
|----------|------|-------------|
| next | NodeApi<T> \| null | Next visible node |
| prev | NodeApi<T> \| null | Previous visible node |
| nextSibling | NodeApi<T> \| null | Next sibling |
| childIndex | number | Index among siblings |
TreeApi
TreeApi provides programmatic control over the entire tree.
Accessing TreeApi
// Via global registry (from anywhere)
import { getTreeApi } from 'react-dendro'
const api = getTreeApi('devices-tree')
api?.scrollTo('node-123')
// Via ref
const treeRef = useRef<TreeApi<MyNode>>(null)
<Tree ref={treeRef} name="my-tree" ... />
treeRef.current?.openAll()
// Get all tree APIs
import { getAllTreeApis } from 'react-dendro'
const allApis = getAllTreeApis()
allApis.forEach(api => api.closeAll())Node Access Methods
| Method | Return | Description |
|--------|--------|-------------|
| get(id) | NodeApi \| null | Get visible node by ID |
| findNode(id) | NodeApi \| null | Find any node by ID |
| at(index) | NodeApi \| null | Get node by visible index |
| indexOf(id) | number \| null | Get visible index |
| nodesBetween(id1, id2) | NodeApi[] | Get range of nodes |
Properties
| Property | Type | Description |
|----------|------|-------------|
| firstNode | NodeApi \| null | First visible node |
| lastNode | NodeApi \| null | Last visible node |
| focusedNode | NodeApi \| null | Currently focused node |
| selectedNodes | NodeApi[] | All selected nodes |
| selectedIds | Set<string> | IDs of selected nodes |
| visibleNodes | NodeApi[] | All visible nodes |
| root | NodeApi | Root node |
| isFiltered | boolean | Search is active |
| hasFocus | boolean | Tree has focus |
Selection Methods
| Method | Description |
|--------|-------------|
| select(id) | Select single node |
| selectMulti(id) | Add to selection |
| selectContiguous(id) | Select range |
| selectAll() | Select all visible |
| deselect(id) | Remove from selection |
| deselectAll() | Clear selection |
| setSelection({ ids, anchor, mostRecent }) | Set selection state |
Open/Close Methods
| Method | Description |
|--------|-------------|
| open(id) | Expand node |
| close(id) | Collapse node |
| toggle(id) | Toggle expand/collapse |
| openParents(id) | Expand ancestors |
| openSiblings(node) | Toggle siblings |
| openAll() | Expand all nodes |
| closeAll() | Collapse all nodes |
Scroll Methods
| Method | Description |
|--------|-------------|
| scrollTo(id, align?) | Scroll to node (align: 'auto', 'start', 'center', 'end') |
| pageUp() | Scroll page up |
| pageDown() | Scroll page down |
Other Methods
| Method | Description |
|--------|-------------|
| focus(id) | Focus node |
| activate(id) | Trigger activation |
| delete(ids) | Delete nodes |
| create(opts) | Create new node |
| createLeaf() | Create leaf node |
| createInternal() | Create folder node |
Drag & Drop
Configuration
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| canDragNode | (node) => boolean | true | Can node be dragged |
| canDropNode | (args) => boolean | true | Can drop at location |
| onlyFolderDrop | boolean | false | Only drop into folders |
| disableDropWhenFiltered | boolean | true | Block drops during search |
| dndRootElement | Node | - | Root for drag preview |
MoveHandler
type MoveHandler<T> = (args: {
dragIds: string[]
dragNodes: NodeApi<T>[]
parentId: string | null
parentNode: NodeApi<T> | null
index: number
sourceTreeName: string | null // For cross-tree drops
targetTreeName: string | null
targetId: string | null
}) => void | Promise<void>Cross-Tree Drag & Drop
react-dendro supports dragging nodes between different trees:
<Tree
name="source-tree"
onMove={async ({ dragIds, sourceTreeName, targetTreeName, targetId }) => {
if (sourceTreeName !== targetTreeName) {
// Cross-tree move
await createLink({
sourceTree: sourceTreeName,
targetTree: targetTreeName,
sourceIds: dragIds,
targetId: targetId,
})
}
}}
>
{/* ... */}
</Tree>Custom Drag Preview
const DragPreview = ({
offset,
isDragging,
dragIds,
sourceTreeName,
targetTreeName,
targetId,
}: DragPreviewProps) => {
if (!isDragging) return null
return (
<div
style={{
position: 'fixed',
left: offset?.x ?? 0,
top: offset?.y ?? 0,
pointerEvents: 'none',
}}
>
Moving {dragIds.length} items
</div>
)
}
<Tree renderDragPreview={DragPreview} ... />Search & Filtering
Basic Search
const [searchTerm, setSearchTerm] = useState('')
<Tree
name="my-tree"
searchTerm={searchTerm}
// ... other props
>
{/* ... */}
</Tree>
<input
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="Search..."
/>Custom Search Match
<Tree
searchMatch={(node, term) => {
// Custom matching logic
return node.data.name.toLowerCase().includes(term.toLowerCase()) ||
node.data.tags?.some(tag => tag.includes(term))
}}
>
{/* ... */}
</Tree>API Reference
Exports
// Components
export { Tree } from './components/Tree'
// Providers
export { TreeStoreProvider } from './providers/TreeStoreProvider'
// Hooks
export { useTreeStore } from './hooks/useTreeStore'
export { useTreeStoresMap } from './hooks/useTreeStoresMap'
// APIs
export { NodeApi } from './interfaces/node-api'
export { TreeApi } from './interfaces/tree-api'
export { getTreeApi, getAllTreeApis } from './store/tree-api-registry'
// Types
export type { TreeProps } from './types/tree-props'
export type { NodeState } from './types/state'
export type {
NodeRendererProps,
RowRendererProps,
DragPreviewProps,
CursorProps
} from './types/renderers'
export type {
CreateHandler,
MoveHandler,
DeleteHandler
} from './types/handlers'Store State
The tree store contains the following slices:
| Slice | Purpose |
|-------|---------|
| DataSlice | Tree data and CRUD operations |
| SelectionSlice | Selected nodes |
| FocusSlice | Focused node |
| OpenSlice | Expanded nodes |
| DragSlice | Drag state |
| DndSlice | Drop cursor state |
Data Slice Methods
| Method | Description |
|--------|-------------|
| setData(data) | Set tree data |
| insertNode(parentId, node) | Insert single node |
| insertNodes(entries) | Insert multiple nodes |
| updateNode(id, patch) | Update single node |
| updateNodes(entries) | Update multiple nodes |
| deleteNode(id) | Delete single node |
| deleteNodes(ids) | Delete multiple nodes |
| getNodeById(id) | Get node data by ID |
| getNodesByIds(ids) | Get multiple nodes |
License
MIT
