Problem Understanding
Most candidates attempt Kanban with a drag-and-drop library. That's a valid production choice, but in interviews it signals you don't understand the underlying API. The interviewer wants to see you implement it with native browser APIs. This demonstrates depth of platform knowledge that library usage hides.
The HTML5 Drag and Drop event sequence on a drag operation:
dragstart — fires on the source element (the card) when drag begins
dragover — fires repeatedly on the target element (the column) as you drag over it
dragleave — fires when you leave the target element
drop — fires on the target element when you release
dragend — fires on the source element after drop
⚠️ Critical Rule: You MUST call e.preventDefault() in the dragover handler. Without it, the browser treats the target as "not a valid drop zone" and the drop event will never fire. This is the single most common reason native DnD doesn't work.
State Shape
const [board, setBoard] = useState({
todo: {
label: 'To Do',
cards: [
{ id: '1', text: 'Design the API' },
{ id: '2', text: 'Write tests' },
],
},
inprogress: {
label: 'In Progress',
cards: [{ id: '3', text: 'Build kanban board' }],
},
done: {
label: 'Done',
cards: [{ id: '4', text: 'Project setup' }],
},
});
The dragItem Ref — Why Not State?
const dragItem = useRef(null); // { cardId, sourceColId }
function handleDragStart(e, cardId, sourceColId) {
dragItem.current = { cardId, sourceColId };
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', ''); // required for Firefox
}
Using a ref (not state) here is intentional. We don't want drag start to trigger a re-render — the component looks the same while a card is being picked up. We just need to remember which card we're dragging for when the drop fires. A ref gives us mutable storage without a re-render.
The Drop Handler — Immutable State Update
function handleDrop(targetColId) {
setDragOver(null);
const { cardId, sourceColId } = dragItem.current;
if (sourceColId === targetColId) return; // no-op: same column
setBoard(prev => {
const sourceCards = prev[sourceColId].cards;
const card = sourceCards.find(c => c.id === cardId);
return {
...prev,
// Remove from source
[sourceColId]: {
...prev[sourceColId],
cards: sourceCards.filter(c => c.id !== cardId),
},
// Add to target
[targetColId]: {
...prev[targetColId],
cards: [...prev[targetColId].cards, card],
},
};
});
dragItem.current = null;
}
This is a single atomic state update — both the removal from source and addition to target happen in one setBoard call. Never mutate the arrays in place.
Column Highlight During Drag
const [dragOver, setDragOver] = useState(null);
// On column div:
onDragOver={e => { e.preventDefault(); setDragOver(colId); }}
onDragLeave={() => setDragOver(null)}
onDrop={() => handleDrop(colId)}
// CSS:
className={`column ${dragOver === colId ? 'column-highlight' : ''}`}
Common Pitfalls Summary
- Missing e.preventDefault() in dragover: Drop event never fires.
- Using state for dragItem: Causes an unnecessary re-render when drag starts. Use a ref.
- Mutating the cards array: board.todo.cards.push(card) breaks React's immutability model. Use filter and spread.
- Not checking sourceColId === targetColId: Dropping on the same column removes the card and adds it back — unnecessary and visually glitchy.
- Not calling dataTransfer.setData() in dragstart: Firefox requires this call even if you don't use the data transfer value. Omitting it breaks drag in Firefox.