Day 58: Build: Drag-and-Drop Kanban Board
Implement drag-and-drop reordering across multiple columns using native HTML5 DnD events and correct, immutable state updates.
Study
Concepts
The native Drag and Drop event sequence
A draggable element fires `dragstart` (where you stash the dragged item's ID, typically via `event.dataTransfer.setData` or a ref/state variable) and `dragend`. A valid drop target must call `event.preventDefault()` inside `dragover` (the browser blocks drops everywhere by default, specifically to prevent accidental drops), and receives the actual `drop` event to perform the state update. `dragenter`/`dragleave` are the pair typically used to toggle a visual "drop here" highlight.
The state-update discipline is the same immutability principle from Day 10: moving a card means removing it from its source column's array and inserting it into the destination column's array, producing brand NEW arrays for both — mutating either array in place risks React missing the update or corrupting shared references if the same array reference is reused elsewhere.
See It
Visualizations
Visualization
Native Drag and Drop event sequence
stash the dragged card's id + source column
preventDefault() required to allow a drop
remove from source, insert into destination
clear drag state, regardless of drop success
Build It
Code Examples
A minimal multi-column Kanban with native DnD
function KanbanBoard({ initialColumns }) {
const [columns, setColumns] = useState(initialColumns); // { todo: [...cards], doing: [...], done: [...] }
const dragInfo = useRef(null); // { cardId, sourceColumnId }
function handleDragStart(cardId, sourceColumnId) {
dragInfo.current = { cardId, sourceColumnId };
}
function handleDrop(targetColumnId, targetIndex) {
const { cardId, sourceColumnId } = dragInfo.current;
if (!cardId) return;
setColumns((prev) => {
const sourceCards = prev[sourceColumnId].filter((c) => c.id !== cardId); // new array
const movedCard = prev[sourceColumnId].find((c) => c.id === cardId);
const targetCards = [...(sourceColumnId === targetColumnId ? sourceCards : prev[targetColumnId])];
targetCards.splice(targetIndex, 0, movedCard); // insert at drop position
return {
...prev,
[sourceColumnId]: sourceColumnId === targetColumnId ? targetCards : sourceCards,
[targetColumnId]: targetCards,
};
});
dragInfo.current = null;
}
return (
<div style={{ display: 'flex', gap: 16 }}>
{Object.entries(columns).map(([columnId, cards]) => (
<div
key={columnId}
onDragOver={(e) => e.preventDefault()} // REQUIRED to allow dropping here at all
onDrop={() => handleDrop(columnId, cards.length)}
style={{ width: 240, minHeight: 400, background: '#f4f4f4', padding: 8 }}
>
<h3>{columnId}</h3>
{cards.map((card, index) => (
<div
key={card.id}
draggable
onDragStart={() => handleDragStart(card.id, columnId)}
onDragOver={(e) => { e.preventDefault(); e.stopPropagation(); }}
onDrop={(e) => { e.stopPropagation(); handleDrop(columnId, index); }}
style={{ padding: 8, marginBottom: 8, background: 'white', borderRadius: 6 }}
>
{card.title}
</div>
))}
</div>
))}
</div>
);
}Remember
Key Takeaways
- dragover must call preventDefault() or the browser blocks the drop entirely — this trips up almost everyone the first time.
- stopPropagation on a card's own dragover/drop lets a specific-index drop win over the column's catch-all end-of-list drop.
- Moving a card = build new arrays for both the source and destination columns — never mutate either in place.
- A ref (not state) is the right place to stash in-flight drag info, since it does not need to trigger a re-render on every dragover.
- For touch-device support and richer accessibility, a library like dnd-kit is preferable in production — this exercise builds the mental model first.
Do It
Practice
- 1Add drop-position visual feedback (a highlighted gap) as the user drags a card over different positions within a column.
- 2Persist column state to localStorage on every change and restore it on reload.
- 3Rebuild the same board using dnd-kit and compare the API ergonomics and touch-device behavior against the native version.