Drag and Drop with Pragmatic DnD
This project uses @atlaskit/pragmatic-drag-and-drop for drag-and-drop functionality.
Required Imports
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine" import { draggable, dropTargetForElements, type ElementDropTargetEventBasePayload, } from "@atlaskit/pragmatic-drag-and-drop/element/adapter" import { pointerOutsideOfPreview } from "@atlaskit/pragmatic-drag-and-drop/element/pointer-outside-of-preview" import { setCustomNativeDragPreview } from "@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview" import { attachClosestEdge, type Edge, extractClosestEdge, } from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge" import { DropIndicator } from "@atlaskit/pragmatic-drag-and-drop-react-drop-indicator/box"
Critical Pattern: Refs for Volatile State
NEVER put volatile drag state in useEffect dependencies. This causes handlers to re-register on every state change.
// BAD - re-registers handlers on every edge change const [closestEdge, setClosestEdge] = useState<Edge | null>(null) useEffect(() => { // ...handlers using closestEdge }, [closestEdge]) // Re-runs on every drag movement!
// GOOD - use ref + useCallback for volatile state const [closestEdge, setClosestEdge] = useState<Edge | null>(null) const closestEdgeRef = useRef<Edge | null>(null)
// Wrap in useCallback for lint compliance (exhaustive-deps) const updateClosestEdge = useCallback((edge: Edge | null) => { closestEdgeRef.current = edge setClosestEdge(edge) // Still update state for rendering }, [])
useEffect(() => { // ...handlers read closestEdgeRef.current instead }, [/* stable deps */, updateClosestEdge]) // Include updateClosestEdge
Import useCallback :
import { useCallback, useEffect, useRef, useState } from "react"
Basic Draggable Item Pattern
function DraggableItem({ item, index, instanceId, onDrop }) { const ref = useRef<HTMLDivElement>(null) const dragHandleRef = useRef<HTMLButtonElement>(null) const [isDragging, setIsDragging] = useState(false) const [closestEdge, setClosestEdge] = useState<Edge | null>(null) const closestEdgeRef = useRef<Edge | null>(null)
const updateClosestEdge = useCallback((edge: Edge | null) => { closestEdgeRef.current = edge setClosestEdge(edge) }, [])
useEffect(() => { const element = ref.current const dragHandle = dragHandleRef.current if (!element || !dragHandle) return
const itemData = { id: item.id, index, instanceId }
return combine(
draggable({
element: dragHandle,
getInitialData: () => itemData,
onDragStart: () => setIsDragging(true),
onDrop: () => setIsDragging(false),
onGenerateDragPreview({ nativeSetDragImage }) {
setCustomNativeDragPreview({
nativeSetDragImage,
getOffset: pointerOutsideOfPreview({ x: "16px", y: "8px" }),
render({ container }) {
const preview = document.createElement("div")
preview.style.cssText = `
background: hsl(var(--background));
border: 2px solid hsl(var(--border));
border-radius: 6px;
padding: 8px 12px;
font-size: 14px;
color: hsl(var(--foreground));
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
`
preview.textContent = item.label
container.appendChild(preview)
},
})
},
}),
dropTargetForElements({
element,
canDrop: ({ source }) =>
source.data.instanceId === instanceId && source.data.index !== index,
getData({ input }) {
return attachClosestEdge(itemData, {
element,
input,
allowedEdges: ["top", "bottom"],
})
},
onDrag({ source, self }: ElementDropTargetEventBasePayload) {
if (source.data.index === index) {
updateClosestEdge(null)
return
}
const edge = extractClosestEdge(self.data)
const sourceIndex = source.data.index
if (typeof sourceIndex !== "number") return
// Hide indicator when it would be redundant
const isItemBeforeSource = index === sourceIndex - 1
const isItemAfterSource = index === sourceIndex + 1
const isDropIndicatorHidden =
(isItemBeforeSource && edge === "bottom") ||
(isItemAfterSource && edge === "top")
updateClosestEdge(isDropIndicatorHidden ? null : edge)
},
onDragLeave: () => updateClosestEdge(null),
onDrop({ source }) {
const sourceIndex = source.data.index
if (typeof sourceIndex === "number" && sourceIndex !== index) {
const edge = closestEdgeRef.current // Read from ref!
const targetIndex = edge === "top" ? index : index + 1
const adjustedTargetIndex =
sourceIndex < targetIndex ? targetIndex - 1 : targetIndex
onDrop(sourceIndex, adjustedTargetIndex)
}
updateClosestEdge(null)
},
}),
)
}, [item.id, item.label, index, instanceId, onDrop, updateClosestEdge])
return ( <div ref={ref} className="relative"> {closestEdge && <DropIndicator edge={closestEdge} gap="2px" />} <div className={isDragging ? "opacity-50" : ""}> <button ref={dragHandleRef} type="button" aria-label="Drag to reorder"> <GripVertical /> </button> {/* Item content */} </div> </div> ) }
Instance ID for Multiple Lists
Use Symbol to scope drag operations to a single list:
function SortableList({ items }) { const [instanceId] = useState(() => Symbol("list")) // Pass instanceId to each item }
Reorder Handler
const handleDrop = async (sourceIndex: number, targetIndex: number) => { const newItems = [...items] const [movedItem] = newItems.splice(sourceIndex, 1) if (movedItem) { newItems.splice(targetIndex, 0, movedItem) const updated = newItems.map((item, i) => ({ ...item, position: i })) setItems(updated) // Optimistic update await saveOrder(updated) // Persist } }
Checklist
-
Refs for volatile state (closestEdge, etc.)
-
Wrap updateClosestEdge in useCallback (lint compliance)
-
Include updateClosestEdge in useEffect deps
-
Instance ID for list scoping
-
Drop indicator with edge detection
-
Custom drag preview
-
Optimistic UI updates