RoadmapDay 58 / 80
React (Web)Month 3 · Week 12

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.

Mark this day complete

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

dragstart

stash the dragged card's id + source column

dragover (target column)

preventDefault() required to allow a drop

drop (target column)

remove from source, insert into destination

dragend

clear drag state, regardless of drop success

Build It

Code Examples

A minimal multi-column Kanban with native DnD

jsx
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

  1. 1Add drop-position visual feedback (a highlighted gap) as the user drags a card over different positions within a column.
  2. 2Persist column state to localStorage on every change and restore it on reload.
  3. 3Rebuild the same board using dnd-kit and compare the API ergonomics and touch-device behavior against the native version.