Writing

Drag and Drop Components

文章發表於

Introduction

Drag and drop (DnD) components are common interactive elements in web applications, ranging from sortable data tables to task boards. This article won't cover overly complex implementations but will instead focus on building a simple draggable component to help readers understand the underlying principles.

The Three Core Concepts of Drag and Drop Components

First, let's introduce the three essential elements that form the core concepts of drag and drop components.

  1. Coordination Layer
  2. Draggable Component
  3. Droppable Component
Context Provider
|
┌──────────┐
↓ ↓
Draggables Droppables
↓ ↓
(Register) (Register)
↓ ↓
(Events) (Collision)

Coordination Layer

The coordination layer is the heart of the drag and drop system. It tracks whether an item is currently being dragged, which element is being dragged, the cursor/touch coordinates, and whether the dragged element is colliding with any droppable areas.

Draggable Component

Represents elements that can be dragged, such as individual records in a data table or tasks on a task board.

Droppable Component

Represents areas that can receive dragged items. Often, elements serve as both draggable and droppable components—each row in a data table or each task on a board can be both dragged and serve as a drop target.

Implementation Key Points

Using the demo above as an example, each item is both draggable and droppable, so they all register with the coordination layer. During dragging, the coordination layer listens for mousemove events to detect collisions between draggable and droppable elements. If a collision occurs, visual feedback is shown (like the blue solid line overlay on hovered items). Finally, when dragging completes with mouseup, the appropriate logic executes (such as reordering items).

Event Listening

Based on the description above, here are the events we need to monitor:

  • mousedown detects the start of dragging
    • The currently dragged item (active)
    • The coordinate offset between cursor and element (dragOffset)
    • Calls the onDragStart event
  • mousemove
    • During dragging, recalculates cursor/touch coordinates (mousePosition)
    • Detects collisions with droppable elements (over) and shows visual feedback if collision occurs
  • mouseup
    • Dragging completes, calls the onDragEnd event
    • Removes collision detection visual feedback
    • Executes appropriate logic (like reordering items)
    • Resets the coordination layer state

Collision Detection

This is why we need to calculate the coordinates of dragged elements—we need to know their position to determine if they're colliding with droppable areas.

const findDroppableUnderPoint = (x, y) => {
const currentActiveId = stateRef.current.active?.id;
const droppables = Array.from(droppablesRef.current.entries()).reverse();
for (const [id, droppable] of droppables) {
const { element, data } = droppable;
if (element && currentActiveId !== id) {
const rect = element.getBoundingClientRect();
if (x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom) {
return { id, data };
}
}
}
return null;
};

Drag the element into the target zone. If the center point of the dragged element collides with the top, bottom, left, or right boundaries of the target zone, the collision detection result will show TRUE; otherwise, it will show FALSE.

useDraggable

useDraggable primarily handles the logic for draggable elements, such as detecting if dragging is active, calculating element coordinates, and registering draggable elements with the coordination layer.

function useDraggable({ id, data }) {
const context = useContext(DndContext);
const nodeRef = useRef(null);
useEffect(() => {
if (nodeRef.current) {
context.registerDraggable(id, nodeRef.current, data);
}
return () => context.unregisterDraggable(id);
}, [id, data, context]);
const isDragging = context.active?.id === id;
const transform = isDragging ? {
x: context.mousePosition.x - context.dragOffset.x,
y: context.mousePosition.y - context.dragOffset.y,
} : null;
return {
setNodeRef: (node) => {
nodeRef.current = node;
},
listeners: {
onMouseDown: (event) => {
context.handleMouseDown(event, id, data);
},
},
isDragging,
transform,
};
}

useDroppable

useDroppable needs to do just two things: register droppable elements and detect if dragged elements are colliding with them.

function useDroppable({ id, data }) {
const context = useContext(DndContext);
const nodeRef = useRef(null);
useEffect(() => {
if (nodeRef.current) {
context.registerDroppable(id, nodeRef.current, data);
}
return () => context.unregisterDroppable(id);
}, [id, data, context]);
const isOver = context.over?.id === id;
return {
setNodeRef: (node) => {
nodeRef.current = node;
},
isOver,
};
}

useSortable

The useSortable hook combines useDraggable and useDroppable into a single hook. This is particularly useful since many components need to be both draggable and droppable.

function useSortable({ id, data }) {
const draggable = useDraggable({ id, data });
const droppable = useDroppable({ id, data });
return {
...draggable,
isOver: droppable.isOver,
// Combine the setNodeRef functions from both hooks
setNodeRef: (node) => {
draggable.setNodeRef(node);
droppable.setNodeRef(node);
},
};
}

Implementation

Code is hidden. Click "Expand code" to view.
import React, { createContext, useContext, useState, useRef, useEffect, useCallback } from 'react';

// ===== 1. DRAG AND DROP CONTEXT =====
// This context provides all the necessary state and functions for the D&D system.
const DndContext = createContext(null);

export function DndProvider({ children, onDragStart, onDragEnd }) {
  // State for active drag
  const [active, setActive] = useState(null);
  const [over, setOver] = useState(null);
  
  // Mouse event tracking
  const [mousePosition, setMousePosition] = useState({ x: 0, y: 0 });
  const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
  
  // Registries for draggables and droppables
  const draggablesRef = useRef(new Map());
  const droppablesRef = useRef(new Map());

  // Fix for Stale Closure - ref holds latest state and props
  const stateRef = useRef({ active, over, onDragEnd });
  
  // Update ref with latest state and props on every render
  useEffect(() => {
    stateRef.current = { active, over, onDragEnd };
  });

  // Collision detection function
  const findDroppableUnderPoint = (x, y) => {
    const currentActiveId = stateRef.current.active?.id;
    const droppables = Array.from(droppablesRef.current.entries()).reverse();
    
    for (const [id, droppable] of droppables) {
      const { element, data } = droppable;
      if (element && currentActiveId !== id) {
        const rect = element.getBoundingClientRect();
        if (x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom) {
          return { id, data };
        }
      }
    }
    return null;
  };

  // Event handlers with useCallback to prevent stale closures
  const handleMouseMove = React.useCallback((event) => {
    setMousePosition({ x: event.clientX, y: event.clientY });
    
    // Find what we're hovering over
    const over = findDroppableUnderPoint(event.clientX, event.clientY);
    setOver(over);
  }, []);

  const handleMouseUp = React.useCallback(() => {
    // Get latest state from ref
    const { active, over, onDragEnd } = stateRef.current;
    
    // Call the onDragEnd callback
    if (onDragEnd && active) {
      onDragEnd({ active, over });
    }

    // Reset all state variables
    setActive(null);
    setOver(null);
    setDragOffset({ x: 0, y: 0 });

    // Remove global event listeners
    window.removeEventListener('mousemove', handleMouseMove);
    window.removeEventListener('mouseup', handleMouseUp);
  }, [handleMouseMove]);

  const handleMouseDown = (event, draggableId, data) => {
    // 1. Get the element and calculate offset
    const draggableElement = draggablesRef.current.get(draggableId)?.element;
    if (!draggableElement) return;
    
    const rect = draggableElement.getBoundingClientRect();

    // 2. Set active item
    setActive({ id: draggableId, data });

    // 3. Calculate initial offset (where within the element they clicked)
    setDragOffset({
      x: event.clientX - rect.left,
      y: event.clientY - rect.top,
    });

    // 4. Set initial mouse position
    setMousePosition({ x: event.clientX, y: event.clientY });

    // 5. Call onDragStart callback
    if (onDragStart) {
      onDragStart({ active: { id: draggableId, data } });
    }

    // 6. Add global mouse event listeners
    window.addEventListener('mousemove', handleMouseMove);
    window.addEventListener('mouseup', handleMouseUp);
    
    // Prevent text selection while dragging
    event.preventDefault();
  };

  // Registration functions
  const registerDraggable = (id, element, data) => {
    draggablesRef.current.set(id, { element, data });
  };

  const unregisterDraggable = (id) => {
    draggablesRef.current.delete(id);
  };

  const registerDroppable = (id, element, data) => {
    droppablesRef.current.set(id, { element, data });
  };

  const unregisterDroppable = (id) => {
    droppablesRef.current.delete(id);
  };

  const contextValue = {
    active,
    over,
    mousePosition,
    dragOffset,
    handleMouseDown,
    registerDraggable,
    unregisterDraggable,
    registerDroppable,
    unregisterDroppable,
  };

  return (
    <DndContext.Provider value={contextValue}>
      {children}
    </DndContext.Provider>
  );
}

// ===== 2. DRAGGABLE HOOK =====
// This hook provides the necessary props to make a component draggable.
export function useDraggable({ id, data }) {
  const context = useContext(DndContext);
  if (!context) throw new Error('useDraggable must be used within a DndProvider');
  
  const nodeRef = useRef(null);

  // Register and unregister the draggable element with the context
  useEffect(() => {
    if (nodeRef.current) {
      context.registerDraggable(id, nodeRef.current, data);
    }
    return () => context.unregisterDraggable(id);
  }, [id, data, context]);

  // Check if this specific item is the one currently being dragged
  const isDragging = context.active?.id === id;

  // Calculate the CSS transform to move the element with the mouse
  const transform = isDragging ? {
    x: context.mousePosition.x - context.dragOffset.x,
    y: context.mousePosition.y - context.dragOffset.y,
  } : null;

  return {
    setNodeRef: (node) => {
      nodeRef.current = node;
    },
    attributes: {
      'data-draggable-id': id,
    },
    listeners: {
      onMouseDown: (event) => {
        context.handleMouseDown(event, id, data);
      },
    },
    isDragging,
    transform,
  };
}

// ===== 3. DROPPABLE HOOK =====
// This hook provides the necessary props to make a component a droppable area.
export function useDroppable({ id, data }) {
  const context = useContext(DndContext);
  if (!context) throw new Error('useDroppable must be used within a DndProvider');

  const nodeRef = useRef(null);

  // Register and unregister the droppable element with the context
  useEffect(() => {
    if (nodeRef.current) {
      context.registerDroppable(id, nodeRef.current, data);
    }
    return () => context.unregisterDroppable(id);
  }, [id, data, context]);

  // Check if a dragged item is currently over this droppable area
  const isOver = context.over?.id === id;

  return {
    setNodeRef: (node) => {
      nodeRef.current = node;
    },
    isOver,
  };
}

// ===== 4. SORTABLE HOOK (COMBINES DRAGGABLE + DROPPABLE) =====
// A convenience hook for items that are both draggable and a drop target.
export function useSortable({ id, data }) {
  const draggable = useDraggable({ id, data });
  const droppable = useDroppable({ id, data });

  return {
    ...draggable,
    isOver: droppable.isOver,
    // Combine the setNodeRef functions from both hooks
    setNodeRef: (node) => {
      draggable.setNodeRef(node);
      droppable.setNodeRef(node);
    },
  };
}

// ===== 5. DEMO COMPONENTS =====

// A sortable item component that uses the useSortable hook.
function SortableItem({ item, index }) {
  const {
    attributes,
    listeners,
    setNodeRef,
    transform,
    isDragging,
    isOver,
  } = useSortable({
    id: item.id,
    data: { item, index },
  });

  // This is the placeholder that stays in the list.
  // When dragging, it becomes invisible but still occupies space.
  const placeholderStyle = {
    visibility: isDragging ? 'hidden' : 'visible',
    // The drop indicator is a line on top of the item being hovered over.
    border: '1px solid #ddd',
    borderTop: isOver ? '2px solid #2196f3' : '1px solid #ddd',
    // Default styling
    padding: '12px',
    margin: '4px 0',
    borderRadius: '4px',
    backgroundColor: 'white',
    transition: 'border-top-color 0.2s ease',
  };
  
  // This is the visual element that follows the mouse.
  // It only appears when dragging.
  const dragOverlayStyle = {
    position: 'fixed',
    top: transform?.y ?? 0,
    left: transform?.x ?? 0,
    width: '580px', // Keep width consistent when dragging
    zIndex: 1000,
    // This is the key change: it prevents the overlay from blocking mouse events.
    pointerEvents: 'none',
    // Visual feedback for the user
    boxShadow: '0 10px 20px rgba(0,0,0,0.19), 0 6px 6px rgba(0,0,0,0.23)',
    cursor: 'grabbing',
    userSelect: 'none',
    // Default styling
    padding: '12px',
    margin: '4px 0',
    borderRadius: '4px',
    backgroundColor: 'white',
    border: '1px solid #ddd',
  };

  return (
    <>
      {/* The placeholder element */}
      <div
        ref={setNodeRef}
        style={placeholderStyle}
        {...attributes}
        {...listeners}
      >
        <strong>{item.name}</strong>
        <br />
        <small>{item.email}</small>
      </div>

      {/* The drag overlay, rendered only when dragging */}
      {isDragging && (
        <div style={dragOverlayStyle}>
          <strong>{item.name}</strong>
          <br />
          <small>{item.email}</small>
        </div>
      )}
    </>
  );
}

// Main Demo App
export default function MiniDndDemo() {
  const [items, setItems] = useState([
    { id: '1', name: 'John Smith', email: '[email protected]' },
    { id: '2', name: 'Jane Doe', email: '[email protected]' },
    { id: '3', name: 'Bob Johnson', email: '[email protected]' },
    { id: '4', name: 'Alice Brown', email: '[email protected]' },
    { id: '5', name: 'Charlie Davis', email: '[email protected]' },
  ]);

  const handleDragStart = (event) => {
    if (event.active) {
      console.log('Drag started:', event.active.data.item.name);
    }
  };

  const handleDragEnd = (event) => {
    const { active, over } = event;
    
    // If active is null, it means the drag ended without a valid start.
    if (!active) {
        return;
    }

    console.log(`Drag ended. Item: ${active.data.item.name}, Over: ${over?.data.item.name || 'nothing'}`);
    
    // If there's no droppable area or the item is dropped on itself, do nothing.
    if (!over || active.id === over.id) {
      return;
    }

    // Reorder the items array based on the drag and drop result
    setItems((currentItems) => {
      const oldIndex = currentItems.findIndex((item) => item.id === active.id);
      const newIndex = currentItems.findIndex((item) => item.id === over.id);
      
      // Create a new array to avoid direct mutation
      const newItems = Array.from(currentItems);
      // Remove the item from its old position
      const [removed] = newItems.splice(oldIndex, 1);
      // Insert the item into its new position
      newItems.splice(newIndex, 0, removed);
      
      return newItems;
    });
  };

  return (
    <div style={{ fontFamily: 'sans-serif', padding: '20px', maxWidth: '600px', margin: '40px auto',  borderRadius: '20px', boxShadow: '0 2px 10px rgba(0,0,0,0.1)' }}>
      <DndProvider onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
        <div style={{ marginTop: '20px' }}>
          <div style={{ cursor: 'grab' }}>
            {items.map((item, index) => (
              <SortableItem key={item.id} item={item} index={index} />
            ))}
          </div>
        </div>
      </DndProvider>
    </div>
  );
}

If you enjoyed this article, please click the buttons below to share it with more people. Your support means a lot to me as a writer.
Buy me a coffee