Fixing HTML5 Drag-and-Drop Reordering That Drops Items in the Wrong Position
Dragging item 3 to the top of a list sometimes reorders correctly and sometimes drops it next to a completely different item than the one it was visually hovering over. The drag handlers themselves fire in the right sequence; what they're actually operating on is index information captured from an earlier render that's since gone stale relative to the list the user is actually looking at.
The Problem
A reorderable list implemented with the native HTML5 Drag and Drop API (draggable, ondragstart, ondragover, ondrop) lets a user drag items to rearrange them. Most drags work correctly, but occasionally an item gets dropped in a position that doesn't match where the user actually released it — sometimes off by one position, sometimes landing next to an entirely different item. The drag gesture itself feels smooth and the visual drop indicator (if there is one) often shows the correct target, yet the resulting list order doesn't match what was shown.
Why It Happens
Drag event handlers frequently close over an index or item reference captured at the time the handler was attached, not at drop time
A common implementation attaches an ondragover or ondrop handler per list item with the item's index baked into the handler via closure (onDrop={() => handleDrop(index)}). If the list re-renders between when the drag started and when the handler was set up — because of unrelated state changes, or because the list itself updates during the drag — the index captured in that closure can reference a stale position that no longer matches the current list order the user is actually seeing.
The native API's dataTransfer object is the only reliable channel for carrying drag payload, and relying on component state instead introduces timing gaps
Storing "the item currently being dragged" in React (or another framework's) state, then reading that state inside the drop handler, works most of the time but introduces a race: if a re-render happens between the drag starting and the drop completing, the state read at drop time may not reflect what was actually initiated at drag-start. The native dataTransfer object exists specifically to carry this information through the drag lifecycle independent of component state's own update timing.
Rapid or overlapping dragover events can fire against a target that's already been visually replaced
If the list re-renders during the drag (updating a "currently hovering over this item" indicator, for instance), a dragover handler attached to what was item 3 a moment ago might now actually be attached to a re-rendered element representing item 4, because the DOM nodes were recreated rather than reused — the event fires correctly against whatever element is currently there, but that's no longer the element the user's cursor position logically corresponds to.
Index-based reordering logic itself can be off-by-one depending on whether the dragged item's original position is accounted for
Separate from timing issues, a reordering calculation that doesn't correctly account for the fact that removing the dragged item from its original position shifts every subsequent index by one can compute the wrong insertion point even with perfectly accurate drag-start and drop-target information — an easy arithmetic mistake that produces an off-by-one symptom that looks like a timing bug but isn't.
The Fix
1. Use dataTransfer to carry the dragged item's identity through the drag lifecycle
function handleDragStart(event, itemId) {
event.dataTransfer.setData("text/plain", itemId); // the identity, not an index
}
function handleDrop(event, targetItemId) {
event.preventDefault();
const draggedItemId = event.dataTransfer.getData("text/plain");
reorderList(draggedItemId, targetItemId); // both read fresh at drop time
}
Storing the dragged item's stable identifier (not a positional index, which can shift) in dataTransfer, and reading both the source and target identities directly at drop time rather than from closures or component state, removes the specific class of bug where stale captured data no longer matches the current list.
2. Reorder by stable item identity, not array index
function reorderList(draggedItemId, targetItemId) {
setItems((prevItems) => {
const items = [...prevItems];
const fromIndex = items.findIndex((item) => item.id === draggedItemId);
const toIndex = items.findIndex((item) => item.id === targetItemId);
const [draggedItem] = items.splice(fromIndex, 1);
items.splice(toIndex, 0, draggedItem);
return items;
});
}
Looking up each item's current index fresh, inside the state updater, based on a stable ID rather than trusting an index value captured earlier, ensures the reorder operates on the list's actual current order rather than a potentially outdated snapshot of positions.
3. Avoid re-rendering the dragged elements themselves during the drag, to prevent DOM node replacement mid-gesture
// Use a stable key tied to item identity, not array index,
// so React reuses the same DOM node across re-renders during the drag
{items.map((item) => (
handleDragStart(e, item.id)}>
{item.label}
))}
Keying list items by a stable ID rather than their array index ensures React reuses the same underlying DOM elements across a re-render, rather than recreating them — which keeps drag event listeners attached to the elements the user is actually interacting with throughout the gesture, rather than to newly-created replacements.
4. Test reordering with a list that updates independently during the drag (e.g., a live sort or filter)
Specifically test: start a drag, then trigger something that causes
the list to re-render (a filter changing, new items arriving) before
releasing the drop — confirm the final position is still correct
Deliberately testing the scenario where the list changes mid-drag — rather than only testing drags on a static, unchanging list — surfaces exactly the timing-dependent bugs that are easy to miss when every manual test happens to complete the drag gesture quickly, before any re-render has a chance to occur.
Why This Works
Each fix removes a different source of stale or mismatched data between when a drag starts and when it completes. Using dataTransfer for identity rather than relying on closures or state reads sidesteps the native API's own timing model correctly; reordering by ID rather than index removes a class of off-by-one and stale-position bugs; stable keys prevent the DOM nodes drag listeners are attached to from being silently replaced mid-gesture; and testing against a list that changes during the drag surfaces exactly the race conditions that a quick, uninterrupted test would never expose.
Conclusion
Drag-and-drop reordering dropping an item in the wrong position usually isn't a bug in the drop logic's arithmetic alone — it's stale positional data, captured before the drag completed, no longer matching the list's actual current state by the time the drop handler runs. Carry the dragged item's stable identity through dataTransfer rather than relying on closures or component state, reorder by looking up fresh indices from stable IDs at drop time, key list items by identity so React doesn't replace the DOM nodes drag listeners depend on, and specifically test drags against a list that updates mid-gesture rather than only a static one.
