Skip to main content

Overview

React Apple Tree uses React DnD to provide powerful drag-and-drop functionality. Nodes can be dragged within the tree or between different trees.

React DnD Integration

Architecture

The drag-and-drop system is built on React DnD with a custom context layer:
src/contexts/DNDContext.tsx
interface DNDContextProps {
  isDraggingNode: boolean | null;
  draggingNodeInformation: DraggingNodeInformation | null;
  dropzoneInformation: DropZoneInformation | null;
  startDrag: (params: StartDragProps) => void;
  onHoverNode: (params: OnHoverNodeProps) => void;
  completeDrop: (isDroppedOutsideTree?: boolean) => void;
}
The DNDContext manages drag state, drop zones, and validates drop operations.

DnD Backend

By default, React Apple Tree uses the HTML5 Backend:
src/ReactAppleTree.tsx
import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';

export default function ReactAppleTree<T>(props) {
  return (
    <DndProvider backend={HTML5Backend}>
      <ReactAppleTreeWithoutDndContext {...props} />
    </DndProvider>
  );
}
If you already have a DndProvider in your app, use ReactAppleTreeWithoutDndContext to avoid nested providers.

Drag Hooks

useDragHook

Internal hook that makes nodes draggable:
src/hooks/dnd/useDragHook.ts
function useDragHook({ nodeIndex, listNode, dndType }) {
  const [{ isDragging }, dragRef, dragPreview] = useDrag({
    type: dndType || DEFAULT_DND_TYPE,
    item: {
      nodeIndex,
      listNode,
      draggingNodeInformation: {
        treeNode,
        flatNode,
        dragStartIndex: nodeIndex,
        dragStartDepth: calculateNodeDepth(flatNode),
        initialExpanded: treeNode.expanded,
        externalDrag: true  // Flag for external drops
      }
    },
    collect: (monitor) => ({
      isDragging: monitor.isDragging()
    })
  });

  return { isDragging, dragRef, dragPreview };
}

useDropHook

Internal hook that makes nodes accept drops:
src/hooks/dnd/useDropHook.ts
function useDropHook({ nodeIndex, listNode, nodeElement, dndType, onHoverNode, completeDrop }) {
  const [{ isOver }, dropRef] = useDrop({
    accept: dndType || DEFAULT_DND_TYPE,
    drop: () => {
      completeDrop();
    },
    hover(item, monitor) {
      const clientOffset = monitor.getClientOffset();
      const targetRect = nodeElement.current.getBoundingClientRect();
      
      // Calculate depth based on horizontal offset
      const offsetX = clientOffset.x - targetRect.left;
      const depth = Math.floor(offsetX / scaffoldBlockPxWidth);
      
      // Calculate direction based on vertical offset
      const offsetY = clientOffset.y - targetRect.top;
      const direction = offsetY < targetRect.height / 2 
        ? NodeAppendDirection.Below 
        : NodeAppendDirection.Above;
      
      onHoverNode({ depth, direction, nodeIndex, flatNode: listNode });
    }
  });

  return { isOver, dropRef };
}

Controlling Drag Behavior

canDrag

Control which nodes can be dragged:
// Boolean: All nodes can/cannot drag
<ReactAppleTree
  treeData={data}
  onChange={setData}
  getNodeKey={({ node }) => node.id}
  canDrag={true}  // or false to disable all dragging
/>

// Function: Conditional dragging
<ReactAppleTree
  treeData={data}
  onChange={setData}
  getNodeKey={({ node }) => node.id}
  canDrag={({ node, path, treeIndex, parentNode }) => {
    // Prevent dragging root nodes
    if (path.length === 1) return false;
    
    // Prevent dragging locked nodes
    if (node.locked) return false;
    
    // Prevent dragging system folders
    if (node.type === 'system') return false;
    
    return true;
  }}
/>
node
TreeItem<T>
The node being checked
path
NumberOrStringArray
Path from root to the node
treeIndex
number
Index of the node in the visible tree
parentNode
TreeItem<T> | undefined
The parent node (undefined for root nodes)
lowerSiblingCounts
number[]
Number of siblings below each ancestor

Controlling Drop Behavior

canDrop

Validate whether a drop operation should be allowed:
<ReactAppleTree
  treeData={data}
  onChange={setData}
  getNodeKey={({ node }) => node.id}
  canDrop={({
    node,           // Node being dropped
    nextParent,     // Proposed new parent
    prevParent,     // Previous parent
    prevPath,       // Previous path
    nextPath,       // Proposed new path
    prevTreeIndex,
    nextTreeIndex
  }) => {
    // Prevent dropping folders into files
    if (nextParent && nextParent.type === 'file') {
      return false;
    }
    
    // Prevent creating deeply nested structures
    if (nextPath.length > 5) {
      return false;
    }
    
    // Prevent moving system files
    if (node.system) {
      return false;
    }
    
    return true;
  }}
/>
canDrop is called during hover to provide visual feedback. Return false to show a red drop zone indicator.

canNodeHaveChildren

Control which nodes can accept children:
// Boolean: All nodes can/cannot have children
<ReactAppleTree
  canNodeHaveChildren={true}
  // ...
/>

// Function: Conditional children
<ReactAppleTree
  canNodeHaveChildren={(node) => {
    // Only folders can have children
    return node.type === 'folder';
  }}
  // ...
/>

DnD Type for External Drops

dndType

Set a custom drag-and-drop type to enable drops from external sources:
// Tree 1: File explorer
<ReactAppleTree
  treeData={fileTree}
  onChange={setFileTree}
  getNodeKey={({ node }) => node.id}
  dndType="FILE_ITEM"
/>

// Tree 2: Trash bin (can accept files)
<ReactAppleTree
  treeData={trashTree}
  onChange={setTrashTree}
  getNodeKey={({ node }) => node.id}
  dndType="FILE_ITEM"  // Same type = can accept drops
/>

// External drop target
import { useDrop } from 'react-dnd';

function ExternalDropZone() {
  const [{ isOver }, dropRef] = useDrop({
    accept: 'FILE_ITEM',
    drop: (item) => {
      console.log('Dropped file:', item.draggingNodeInformation.treeNode);
    }
  });
  
  return <div ref={dropRef}>Drop files here</div>;
}
Default type allows dragging within the same tree:
<ReactAppleTree
  treeData={data}
  onChange={setData}
  getNodeKey={({ node }) => node.id}
  // dndType defaults to "REACT_APPLE_TREE_ITEM"
/>

shouldCopyOnOutsideDrop

Control whether nodes are copied or moved when dropped outside the tree:
// Boolean: Always copy (or always move)
<ReactAppleTree
  treeData={data}
  onChange={setData}
  getNodeKey={({ node }) => node.id}
  shouldCopyOnOutsideDrop={true}  // Copy on external drop
/>

// Function: Conditional copy/move
<ReactAppleTree
  treeData={data}
  onChange={setData}
  getNodeKey={({ node }) => node.id}
  shouldCopyOnOutsideDrop={({ node, prevPath, prevTreeIndex }) => {
    // Copy template nodes, move regular nodes
    return node.isTemplate === true;
  }}
/>
Copy (true): Node remains in the tree and is duplicated to the drop targetMove (false): Node is removed from the tree and added to the drop target

Drag Lifecycle

Drag Start

When a drag operation begins:
<ReactAppleTree
  treeData={data}
  onChange={setData}
  getNodeKey={({ node }) => node.id}
  onDragStateChanged={({ isDragging, draggedNode }) => {
    if (isDragging) {
      console.log('Started dragging:', draggedNode.title);
      setDraggingState(true);
    }
  }}
/>
1

User clicks drag handle

The drag operation is initiated on the node
2

startDrag is called

function startDrag(params: StartDragProps) {
  const flatNode = flatTree[params.nodeIndex];
  const treeNode = treeMap[flatNode.mapId];
  
  // Store dragging node information
  setDraggingNodeInformation({
    treeNode,
    flatNode,
    dragStartIndex: params.nodeIndex,
    dragStartDepth: calculateNodeDepth(flatNode),
    initialExpanded: treeNode.expanded
  });
  
  // Collapse the dragging node
  const flatArray = collapseNode(flatNode.mapId, treeNode, treeMap, flatTree);
  setFlatTree([...flatArray]);
  setIsDraggingNode(true);
}
3

onDragStateChanged fires

Callback is invoked with isDragging: true

Drag Hover

As the cursor hovers over potential drop targets:
function onHoverNode(params: OnHoverNodeProps) {
  // Calculate temporary drop position
  let hoverDropIndex = calculateTempDropIndex(params);
  
  // Remove dragging node placeholder if it exists
  [hoverDropIndex, newFlatList] = removeDragggingNodeIfExists(
    hoverDropIndex,
    newFlatList,
    draggingNodeInformation
  );
  
  // Calculate actual drop depth (respecting maxDepth, canNodeHaveChildren)
  const hoverDropDepth = calculateActualDropDepth(
    hoverDropIndex,
    tmpDropDepth,
    newTreeMap,
    newFlatList,
    draggingNodeInformation,
    dropzoneInformation,
    canNodeHaveChildren
  );
  
  // Check canDrop validation
  let canDrop = true;
  if (canDropFn) {
    canDrop = canDropFn(nodeMoveData);
  }
  
  // Create dropzone indicator
  newFlatList = constructDropzone(
    hoverDropIndex,
    hoverDropDepth,
    canDrop,
    newFlatList,
    draggingNodeInformation
  );
  
  // Update UI
  setFlatTree([...newFlatList]);
  setDropzoneInformation({ /* ... */ });
}
The drop zone indicator shows a visual preview of where the node will be placed. Green indicates a valid drop, red indicates canDrop returned false.

Drop Complete

When the node is dropped:
<ReactAppleTree
  treeData={data}
  onChange={setData}
  getNodeKey={({ node }) => node.id}
  onMoveNode={({
    node,          // The moved node
    treeData,      // Updated tree data
    prevPath,      // Previous path
    nextPath,      // New path
    prevTreeIndex,
    nextTreeIndex,
    nextParentNode // New parent (null for root)
  }) => {
    console.log(`Moved ${node.title} from`, prevPath, 'to', nextPath);
    
    // Save to backend
    api.moveNode(node.id, nextParentNode?.id);
  }}
/>
1

User releases mouse

Drop event is triggered
2

completeDrop is called

function completeDrop(isDroppedOutsideTree: boolean = false) {
  if (isDroppedOutsideTree) {
    newTree = appleTreeProps.treeData;  // No change
  } else {
    newTree = moveNodeToDifferentParent(
      appleTreeProps.treeData,
      treeMap,
      draggingNodeInformation.flatNode.mapId,
      draggingNodeInformation.flatNode.parentKey,
      dropzoneInformation.nextParentKey,
      dropzoneInformation.siblingIndex,
      appleTreeProps.getNodeKey
    );
  }
  
  // Restore original expansion state
  if (draggingNodeInformation.initialExpanded) {
    draggingNodeInformation.treeNode.expanded = true;
  }
  
  // Fire callback
  appleTreeProps.onMoveNode?.({
    ...dropzoneInformation.nodeMoveData,
    treeData: newTree,
    nextParentNode: dropzoneInformation.nodeMoveData.nextParent
  });
  
  setAppleTreeProps({ treeData: [...newTree] });
  setDraggingNodeInformation(null);
  setIsDraggingNode(false);
}
3

onChange is called

Component receives the updated tree data
4

onMoveNode fires

Callback receives move details

Customizing Drag Behavior

Visual Feedback

Customize the drag preview and drop indicators:
<ReactAppleTree
  treeData={data}
  onChange={setData}
  getNodeKey={({ node }) => node.id}
  generateNodeProps={({ node, isDragging }) => ({
    style: {
      opacity: isDragging ? 0.5 : 1,
      backgroundColor: isDragging ? '#e3f2fd' : 'transparent'
    }
  })}
  placeholderRenderer={({ isOver, canDrop, draggedNode }) => (
    <div
      style={{
        height: 4,
        backgroundColor: canDrop ? '#4caf50' : '#f44336',
        opacity: isOver ? 1 : 0.5
      }}
    >
      {!canDrop && <span>Cannot drop here</span>}
    </div>
  )}
/>

Constrain Movement

Limit where nodes can be moved:
<ReactAppleTree
  treeData={data}
  onChange={setData}
  getNodeKey={({ node }) => node.id}
  maxDepth={5}  // Maximum nesting depth
  canDrop={({ node, nextPath, nextParent }) => {
    // Prevent moving to root level
    if (nextPath.length === 1) return false;
    
    // Prevent moving files into other files
    if (nextParent && nextParent.type === 'file') return false;
    
    // Prevent moving parents into their own descendants
    if (nextPath.some(key => key === node.id)) return false;
    
    return true;
  }}
/>

Drag Handles

By default, the entire node is draggable. Customize with generateNodeProps:
<ReactAppleTree
  treeData={data}
  onChange={setData}
  getNodeKey={({ node }) => node.id}
  generateNodeProps={({ node }) => ({
    buttons: [
      <button
        key="drag"
        className="drag-handle"
        style={{ cursor: 'grab' }}
      >
        ⋮⋮
      </button>
    ]
  })}
/>

Advanced Patterns

Multi-Tree Drag and Drop

const SHARED_DND_TYPE = 'SHARED_TREE_ITEM';

function MultiTreeApp() {
  return (
    <DndProvider backend={HTML5Backend}>
      <div style={{ display: 'flex', gap: 20 }}>
        <ReactAppleTreeWithoutDndContext
          treeData={tree1}
          onChange={setTree1}
          getNodeKey={({ node }) => node.id}
          dndType={SHARED_DND_TYPE}
          shouldCopyOnOutsideDrop={true}
        />
        
        <ReactAppleTreeWithoutDndContext
          treeData={tree2}
          onChange={setTree2}
          getNodeKey={({ node }) => node.id}
          dndType={SHARED_DND_TYPE}
        />
      </div>
    </DndProvider>
  );
}

Undo/Redo with Drag Operations

function TreeWithUndo() {
  const [history, setHistory] = useState([initialTree]);
  const [currentIndex, setCurrentIndex] = useState(0);
  
  const currentTree = history[currentIndex];
  
  const handleChange = (newTree) => {
    const newHistory = history.slice(0, currentIndex + 1);
    setHistory([...newHistory, newTree]);
    setCurrentIndex(currentIndex + 1);
  };
  
  return (
    <>
      <button onClick={() => setCurrentIndex(currentIndex - 1)} disabled={currentIndex === 0}>
        Undo
      </button>
      <button onClick={() => setCurrentIndex(currentIndex + 1)} disabled={currentIndex === history.length - 1}>
        Redo
      </button>
      
      <ReactAppleTree
        treeData={currentTree}
        onChange={handleChange}
        getNodeKey={({ node }) => node.id}
      />
    </>
  );
}

Next Steps

Component Overview

Understand the component architecture

Callbacks

Complete reference for all callbacks

Build docs developers (and LLMs) love