Drag and Drop Sortable List
Prompt
Build a sortable list in React where items can be reordered using drag and drop. Use the native HTML5 Drag and Drop API (no external libraries). Items should provide visual feedback during the drag to show where the item will be placed.
Requirements
- Render a list of items from the provided data
- Each item can be dragged to a new position in the list
- Show a visual indicator highlighting where the dragged item will be dropped
- The dragged item should appear dimmed in its original position
- Update the item order when the item is dropped
- Reset all visual states when the drag ends, even if dropped outside the list
Example

Playground
Look into the HTML5 Drag and Drop API. What attribute makes an element draggable? There are four key events you will need. Which one fires when the drag starts, which one fires while hovering over a target, which one fires on release, and which one always fires when the drag ends regardless of outcome?
You need to track two things during a drag: where the item came from and where it is hovering now. One of these drives a visual indicator (needs re-renders), the other is only read at drop time (does not). Which should be state and which should be a ref? Also, by default the browser does not allow dropping. What do you need to call in onDragOver to permit it?
To move an item from one position to another in an array, you can splice it out and splice it back in at the new index. What happens if the user drops outside the list or presses Escape? Which event would you use to make sure visual states always get cleaned up?
Solution
Solution with useState
Solution with useRef (Alternative)
Explanation
Project Structure
We split the code into focused files:
App.jsholds the static data and rendersSortableListcomponents/SortableList.jsmanages drag state and the reorder logiccomponents/SortableItem.jsis a presentational component for each draggable item
HTML5 Drag and Drop API
The browser provides a built-in drag and drop system through a set of events on DOM elements. To make an element draggable, add the draggable attribute:
<li draggable onDragStart={...} onDragOver={...} onDrop={...} onDragEnd={...}>The key events are:
onDragStart: fires once when the user starts dragging. We record which item is being dragged.onDragOver: fires repeatedly while the dragged item hovers over an element. We must calle.preventDefault()here. Without it, the browser treats the element as a non-drop target and blocks theonDropevent entirely.onDrop: fires when the user releases the item over a valid drop target. We perform the reorder here.onDragEnd: fires when the drag operation ends, regardless of whether the drop was successful. We use this for cleanup.
Drag State Management
We track both the drag source index and the drop target index with useState:
const [dragIndex, setDragIndex] = useState(null);
const [overIndex, setOverIndex] = useState(null);Both values follow the same pattern, making the code straightforward to read and write. When the user starts dragging, we store the source index. When the user drags over a different item, we store the target index to drive the visual highlight.
const handleDragStart = (index) => {
setDragIndex(index);
};
const handleDrop = () => {
if (
dragIndex === null ||
overIndex === null ||
dragIndex === overIndex
) {
resetDragState();
return;
}
setItems((prev) => {
const result = [...prev];
const [removed] = result.splice(dragIndex, 1);
result.splice(overIndex, 0, removed);
return result;
});
resetDragState();
};
const resetDragState = () => {
setDragIndex(null);
setOverIndex(null);
};Alternative: Drag index in useRef
Since the drag source index does not drive any visual change (we only read it at drop time), you can store it in a useRef instead of useState to avoid one extra re-render on drag start:
const [overIndex, setOverIndex] = useState(null);
const dragIndexRef = useRef(null);
const handleDragStart = (index) => {
dragIndexRef.current = index;
};useState approach (used in our solution):
- Simpler to read and understand. Both values follow the same pattern
- Triggers one extra re-render on drag start that does not change anything visually
- Perfectly fine for small lists (5-10 items) where that extra render is negligible
useRef approach:
- No re-render on drag start since setting a ref does not trigger one
- Slightly more code to read (
.currentaccess) - Better choice when the list is large and re-renders are costly
Both approaches are correct. In an interview, the useState version is faster to write. If the interviewer asks about performance, you can mention the useRef optimization and explain why it avoids the extra re-render.
The Reorder Logic
When the user drops, we remove the item from its original position and insert it at the target position:
const handleDrop = () => {
if (
dragIndex === null ||
overIndex === null ||
dragIndex === overIndex
) {
resetDragState();
return;
}
setItems((prev) => {
const result = [...prev];
const [removed] = result.splice(dragIndex, 1);
result.splice(overIndex, 0, removed);
return result;
});
resetDragState();
};The guard clause handles three cases: no drag started, no drop target, or dropping on yourself. In all cases we just clean up without reordering.
The splice logic is the core of the reorder. Let's walk through an example. Say we have ['A', 'B', 'C', 'D', 'E'] and drag item 'B' (index 1) to where 'D' is (index 3):
const result = [...prev]creates a copy:['A', 'B', 'C', 'D', 'E']result.splice(1, 1)removes 1 item at index 1. The array becomes['A', 'C', 'D', 'E']and returns['B'].result.splice(3, 0, 'B')inserts'B'at index 3 without removing anything. The array becomes['A', 'C', 'D', 'B', 'E'].
splice(index, deleteCount, ...items) does two things: it removes deleteCount items starting at index, and then inserts any additional items at that same position. In step 2 we use it to remove. In step 3 we pass 0 as deleteCount so it only inserts.
Visual Feedback
Two CSS classes provide drag feedback:
.item.dragging {
opacity: 0.4;
}
.item.over {
border-color: var(--color-accent);
background: var(--color-accent-light);
}The .dragging class dims the original item so the user knows what they picked up. The .over class highlights the current drop target with a colored border and background. These classes are applied based on index comparisons:
isDragging={dragIndex === index}
isOver={overIndex === index}The SortableItem Component
const SortableItem = memo(function SortableItem({
item,
index,
isDragging,
isOver,
onDragStart,
onDragOver,
onDrop,
onDragEnd,
}) {
let className = 'item';
if (isDragging) className += ' dragging';
if (isOver) className += ' over';
return (
<li
className={className}
draggable
role="option"
aria-grabbed={isDragging}
aria-label={item.text}
onDragStart={() => onDragStart(index)}
onDragOver={(e) => onDragOver(e, index)}
onDrop={onDrop}
onDragEnd={onDragEnd}
>
<span className="drag-handle" aria-hidden="true">
â ¿
</span>
<span>{item.text}</span>
</li>
);
});Each item is an li inside an ol with role="listbox". This is semantically correct because we are rendering an ordered list of items where the order matters. Each item uses role="option" and aria-grabbed to communicate drag state to screen readers. The drag handle icon is marked aria-hidden="true" since it is purely decorative.
Why li instead of button?
In other questions (like Color Swatch), we use button elements because those are action targets. You click a swatch to select it. Here, the items are not action targets. They are content in a reorderable list. A button communicates "click me to do something," but a list item communicates "I am a piece of content in a sequence." Using button here would confuse screen readers into announcing each item as a clickable action, when the real interaction is drag and drop. The right semantic choice is li inside an ol, enhanced with ARIA roles for drag-and-drop context.
Each item receives its index and all drag event handlers from the parent. The onDragStart and onDragOver handlers wrap the parent's functions to pass the index. We use React.memo to skip re-renders for items whose props have not changed. Since most items are not involved in the current drag operation, only the dragged and hovered items re-render.
The drag handle icon (â ¿) gives users a visual cue that the item is draggable. The entire item is draggable (not just the handle) for simplicity.
Cleanup
const handleDragEnd = () => {
resetDragState();
};
const resetDragState = () => {
setDragIndex(null);
setOverIndex(null);
};onDragEnd always fires when a drag operation ends, whether the drop was successful or not. This includes pressing Escape to cancel, dropping outside the list, or dropping on a non-target area. By resetting all drag state in onDragEnd, we guarantee the UI never gets stuck in a "dragging" visual state.
A common mistake is only cleaning up drag state in the onDrop handler. If the user drops outside the list or presses Escape, onDrop never fires, but onDragEnd always does. Always reset drag state in onDragEnd.
Alternative: Using dataTransfer
The HTML5 Drag and Drop API includes a dataTransfer object for passing data between drag events. You can use e.dataTransfer.setData('text/plain', index) in onDragStart and e.dataTransfer.getData('text/plain') in onDrop. However, getData is not available in onDragOver (blocked for security), so you still need a separate mechanism to track the drag index for visual feedback. Using a useRef is simpler and avoids the dataTransfer API entirely.
Interviewer Criteria
HTML & CSS
Uses semantic ol and li elements since the items are an ordered list where position matters.
Items use the draggable attribute and have a grab cursor to indicate they can be dragged.
Visual feedback provided with opacity on the dragged item and a highlight on the drop target.
CSS variables used for colors and transitions added for smooth visual state changes.
JavaScript
Calls e.preventDefault() in onDragOver to allow the drop event to fire.
Reorder logic correctly uses splice to remove the item from the old position and insert at the new one.
Handled edge case: guard clause prevents reorder when from and to indices are the same.
Handled edge case: drag state is reset in onDragEnd to handle drops outside the list or Escape cancels.
Handled edge case: null checks on dragIndex and overIndex before attempting reorder.
React
Used useRef for the drag source index since it does not drive visual updates, avoiding unnecessary re-renders.
Used useState for the over index since it controls the visual drop indicator.
Used functional state update for reordering to avoid stale state issues.
Split into a presentational SortableItem component and a logic-heavy SortableList component.
Wrapped SortableItem in React.memo to skip re-renders for items not involved in the drag.
Used item.id as the key (not array index) so React correctly tracks items after reordering.
Accessibility
List uses role="listbox" and items use role="option" to communicate the reorderable list pattern to screen readers.
aria-grabbed on each item communicates whether it is currently being dragged.
Drag handle icon is marked aria-hidden since it is purely decorative.
user-select: none prevents accidental text selection during drag operations.
Code Quality
Drag cleanup is extracted into a reusable resetDragState function called from both onDrop and onDragEnd.
Clean separation: data in App.js, logic in SortableList.js, presentation in SortableItem.js.
Variable names clearly communicate intent: dragIndex, overIndex, resetDragState.
Time Checkpoints
- 10:00 AM
Interview starts 👥
- 10:03 AM
Prompt given by the interviewer
- 10:05 AM
Candidate reads the prompt, asks clarifying questions, and starts coding
- 10:12 AM
Render the list of items with basic styling and the draggable attribute
- 10:20 AM
Implement onDragStart and onDragOver with e.preventDefault()
- 10:28 AM
Implement onDrop with splice-based reorder logic
- 10:34 AM
Add visual feedback: dimmed dragged item and highlighted drop target
- 10:38 AM
Add onDragEnd cleanup and guard clauses for edge cases
- 10:42 AM
Extract SortableItem component with React.memo
- 10:45 AM
Discussions with interviewer
- 10:45 AM
Interview ends ✅