React Kanban Board Tutorial: Build Drag-and-Drop
A React kanban board is a task-management interface where users visually organize work by dragging card components between column containers. In this article, you'll build a three-column kanban board (To Do, In Progress, Done) with fully functional drag-and-drop interactions, demonstrating the component hierarchy, state lifting, event handlers, and conditional rendering patterns that power production task managers.
Understanding Kanban Board Architecture
A kanban board organizes work items into vertical columns representing workflow stages. Each column contains draggable card elements; users drag cards to transition them between stages. The React component tree mirrors this structure: a parent Board component manages columns, each Column component renders a list of Card components, and drag-event handlers update shared state when a card is dropped.
This architecture separates concerns: the board tracks global state, columns render their assigned cards, and cards are presentational components that emit drag events. By lifting state to the board level, you ensure all columns share the same task data source, so dragging a card into another column instantly updates that column's display.
Setting Up Your Kanban Component Structure
Begin by defining your data structure and core components:
// types and initial state
const initialTasks = {
'todo': [
{ id: '1', title: 'Design API', description: 'Plan REST endpoints' },
{ id: '2', title: 'Setup database', description: 'Configure PostgreSQL' }
],
'inProgress': [
{ id: '3', title: 'Implement auth', description: 'JWT token flow' }
],
'done': [
{ id: '4', title: 'Project kickoff', description: 'Stakeholder alignment' }
]
};
function Board() {
const [tasks, setTasks] = React.useState(initialTasks);
const [draggedTask, setDraggedTask] = React.useState(null);
// Handlers will go here
return (
<div className="board">
<Column
title="To Do"
columnId="todo"
tasks={tasks.todo}
onDragStart={handleDragStart}
onDrop={handleDrop}
onDragOver={handleDragOver}
/>
<Column
title="In Progress"
columnId="inProgress"
tasks={tasks.inProgress}
onDragStart={handleDragStart}
onDrop={handleDrop}
onDragOver={handleDragOver}
/>
<Column
title="Done"
columnId="done"
tasks={tasks.done}
onDragStart={handleDragStart}
onDrop={handleDrop}
onDragOver={handleDragOver}
/>
</div>
);
}
Your Board component holds all tasks in state indexed by column ID (todo, inProgress, done). Each column receives the task array for its stage plus three event handlers. The draggedTask state tracks which card is currently being dragged so you can add visual feedback.
Implementing Drag Event Handlers
The HTML Drag and Drop API fires events at different lifecycle points. onDragStart captures which card is being dragged; onDragOver signals drop zones; onDrop completes the operation:
function Board() {
const [tasks, setTasks] = React.useState(initialTasks);
const [draggedTask, setDraggedTask] = React.useState(null);
const [sourceColumn, setSourceColumn] = React.useState(null);
function handleDragStart(taskId, columnId) {
setDraggedTask(taskId);
setSourceColumn(columnId);
}
function handleDragOver(event) {
// Prevent default to allow drop
event.preventDefault();
event.dataTransfer.dropEffect = 'move';
}
function handleDrop(event, targetColumnId) {
event.preventDefault();
if (!draggedTask || !sourceColumn) return;
// Don't reorder within same column here (simple version)
if (sourceColumn === targetColumnId) {
setDraggedTask(null);
setSourceColumn(null);
return;
}
// Move task from source column to target column
setTasks(prevTasks => {
const sourceCards = [...prevTasks[sourceColumn]];
const targetCards = [...prevTasks[targetColumnId]];
// Find and remove task from source
const taskIndex = sourceCards.findIndex(t => t.id === draggedTask);
const [movedTask] = sourceCards.splice(taskIndex, 1);
// Add task to target
targetCards.push(movedTask);
return {
...prevTasks,
[sourceColumn]: sourceCards,
[targetColumnId]: targetCards
};
});
setDraggedTask(null);
setSourceColumn(null);
}
// ... JSX as above
}
The handleDragStart handler captures the task and source column IDs. handleDragOver calls preventDefault() to signal that this zone accepts drops; without it, onDrop never fires. handleDrop reads the source and target IDs, removes the task from the source column array, appends it to the target array, and clears the drag state.
Building the Column and Card Components
Create child components for columns and cards to keep your component tree clean:
function Column({ title, columnId, tasks, onDragStart, onDrop, onDragOver }) {
return (
<div className="column">
<h2>{title}</h2>
<div
className="drop-zone"
onDragOver={onDragOver}
onDrop={(e) => onDrop(e, columnId)}
>
{tasks.map(task => (
<Card
key={task.id}
task={task}
onDragStart={() => onDragStart(task.id, columnId)}
/>
))}
</div>
</div>
);
}
function Card({ task, onDragStart }) {
return (
<div
className="card"
draggable
onDragStart={onDragStart}
>
<h3>{task.title}</h3>
<p>{task.description}</p>
</div>
);
}
The Column component renders a drop-zone div that accepts the onDrop handler with its column ID. The Card component sets draggable={true} to enable drag mode and calls onDragStart when the drag begins.
Adding Visual Feedback During Drag
Enhance user experience by highlighting the dragged card and valid drop zones:
function Card({ task, isDragging, onDragStart }) {
return (
<div
className={`card ${isDragging ? 'dragging' : ''}`}
draggable
onDragStart={onDragStart}
>
<h3>{task.title}</h3>
<p>{task.description}</p>
</div>
);
}
function Column({ title, columnId, tasks, draggedTaskId, onDragStart, onDrop, onDragOver }) {
return (
<div className="column">
<h2>{title}</h2>
<div
className="drop-zone"
onDragOver={onDragOver}
onDrop={(e) => onDrop(e, columnId)}
>
{tasks.map(task => (
<Card
key={task.id}
task={task}
isDragging={task.id === draggedTaskId}
onDragStart={() => onDragStart(task.id, columnId)}
/>
))}
</div>
</div>
);
}
Pass draggedTaskId and isDragging props to conditionally apply a dragging CSS class that reduces opacity or adds a border, signaling to users that the card is being moved.
Key Takeaways
- State lifting: Keep all task data in the parent
Boardcomponent so all columns sync instantly when a card moves. - Drag event sequence:
onDragStartcaptures the source;onDragOverenables the drop zone;onDropmutates state. - Component decomposition: Separate
Board,Column, andCardto isolate concerns and reuse event handlers. - Immutable updates: Always create new arrays/objects when updating state to trigger re-renders reliably.
- Visual feedback: Highlight the dragged card and drop zones to help users understand drag interactions.
Frequently Asked Questions
Why do I need to call preventDefault() in onDragOver?
By default, the browser prevents drops. Calling event.preventDefault() signals that this element is a valid drop target, enabling the onDrop event to fire. Without it, onDrop never triggers.
Can I drag cards within the same column to reorder them?
Yes. The handler above skips same-column drops for simplicity, but you can store the drop position (index) and insert the card at that position rather than at the end. You'll also need to track the y-coordinate of the drag to compute where in the column to insert.
How do I prevent users from dragging completed tasks?
Add a draggable prop that conditionally disables drag: <div draggable={!task.completed}>. You can also ignore the onDragStart call if the task is marked done, though disabling the attribute is clearer UX.
What's the difference between draggable HTML5 and libraries like React DnD?
HTML5 Drag and Drop is a browser API available to all elements and works with simple use cases. React DnD (covered in the next article) provides a higher-level abstraction with monitors, drag previews, and better handling of touch devices and complex reordering.
How do I detect if a drop happened outside the board?
If the drop target is outside your board, onDrop on the board never fires, so draggedTask remains in state. You can use onDragEnd (fired on the card after any drag concludes) to clear draggedTask if no valid drop occurred.