Build a Kanban Board with Drag & Drop
Interview Question: "Build a Kanban board where tasks can be dragged between columns (Todo, In Progress, Done)."
Requirements
- Multiple columns with configurable labels
- Add/edit/delete tasks
- Drag tasks between columns and reorder within columns
- Persist state
- Visual drag feedback
Data Model
interface Task {
id: string;
title: string;
description?: string;
columnId: string;
order: number;
}
interface Column {
id: string;
title: string;
}
interface BoardState {
columns: Column[];
tasks: Task[];
}State Management
type BoardAction =
| { type: 'ADD_TASK'; columnId: string; title: string }
| { type: 'MOVE_TASK'; taskId: string; toColumnId: string; toIndex: number }
| { type: 'DELETE_TASK'; taskId: string }
| { type: 'EDIT_TASK'; taskId: string; title: string };
function boardReducer(state: BoardState, action: BoardAction): BoardState {
switch (action.type) {
case 'ADD_TASK': {
const tasksInColumn = state.tasks.filter(t => t.columnId === action.columnId);
const newTask: Task = {
id: crypto.randomUUID(),
title: action.title,
columnId: action.columnId,
order: tasksInColumn.length,
};
return { ...state, tasks: [...state.tasks, newTask] };
}
case 'MOVE_TASK': {
const task = state.tasks.find(t => t.id === action.taskId);
if (!task) return state;
const withoutTask = state.tasks.filter(t => t.id !== action.taskId);
const targetTasks = withoutTask
.filter(t => t.columnId === action.toColumnId)
.sort((a, b) => a.order - b.order);
targetTasks.splice(action.toIndex, 0, {
...task,
columnId: action.toColumnId,
});
const reordered = targetTasks.map((t, i) => ({ ...t, order: i }));
const otherTasks = withoutTask.filter(t => t.columnId !== action.toColumnId);
return { ...state, tasks: [...otherTasks, ...reordered] };
}
case 'DELETE_TASK':
return { ...state, tasks: state.tasks.filter(t => t.id !== action.taskId) };
case 'EDIT_TASK':
return {
...state,
tasks: state.tasks.map(t =>
t.id === action.taskId ? { ...t, title: action.title } : t
),
};
}
}Drag & Drop with HTML5 API
function TaskCard({
task,
onDelete,
}: {
task: Task;
onDelete: () => void;
}) {
const handleDragStart = (e: DragEvent<HTMLDivElement>) => {
e.dataTransfer.setData('text/plain', task.id);
e.dataTransfer.effectAllowed = 'move';
(e.target as HTMLElement).classList.add('dragging');
};
const handleDragEnd = (e: DragEvent<HTMLDivElement>) => {
(e.target as HTMLElement).classList.remove('dragging');
};
return (
<div
className="task-card"
draggable
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
role="listitem"
>
<p>{task.title}</p>
<button onClick={onDelete} aria-label={`Delete ${task.title}`}>Ć</button>
</div>
);
}
function KanbanColumn({
column,
tasks,
dispatch,
}: {
column: Column;
tasks: Task[];
dispatch: Dispatch<BoardAction>;
}) {
const [input, setInput] = useState('');
const handleDragOver = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
};
const handleDrop = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
const taskId = e.dataTransfer.getData('text/plain');
const dropTarget = e.target as HTMLElement;
const cardEl = dropTarget.closest('.task-card');
let toIndex = tasks.length;
if (cardEl) {
const cardId = tasks.find(t =>
cardEl.textContent?.includes(t.title)
)?.id;
const idx = tasks.findIndex(t => t.id === cardId);
if (idx >= 0) toIndex = idx;
}
dispatch({ type: 'MOVE_TASK', taskId, toColumnId: column.id, toIndex });
};
const handleAdd = (e: FormEvent) => {
e.preventDefault();
if (!input.trim()) return;
dispatch({ type: 'ADD_TASK', columnId: column.id, title: input.trim() });
setInput('');
};
return (
<div
className="kanban-column"
onDragOver={handleDragOver}
onDrop={handleDrop}
>
<h3>{column.title} ({tasks.length})</h3>
<div className="task-list" role="list">
{tasks.sort((a, b) => a.order - b.order).map(task => (
<TaskCard
key={task.id}
task={task}
onDelete={() => dispatch({ type: 'DELETE_TASK', taskId: task.id })}
/>
))}
</div>
<form onSubmit={handleAdd}>
<input
value={input}
onChange={e => setInput(e.target.value)}
placeholder="Add task..."
/>
</form>
</div>
);
}Board Component
const INITIAL_STATE: BoardState = {
columns: [
{ id: 'todo', title: 'To Do' },
{ id: 'in-progress', title: 'In Progress' },
{ id: 'done', title: 'Done' },
],
tasks: [],
};
function KanbanBoard() {
const [state, dispatch] = useReducer(boardReducer, INITIAL_STATE);
return (
<div className="kanban-board">
{state.columns.map(column => (
<KanbanColumn
key={column.id}
column={column}
tasks={state.tasks.filter(t => t.columnId === column.id)}
dispatch={dispatch}
/>
))}
</div>
);
}CSS
.kanban-board {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 1rem;
padding: 1rem;
}
.kanban-column {
background: #f8fafc;
border-radius: 8px;
padding: 1rem;
min-height: 400px;
}
.task-card {
background: white;
border: 1px solid #e2e8f0;
border-radius: 6px;
padding: 0.75rem;
margin-bottom: 0.5rem;
cursor: grab;
transition: box-shadow 150ms, opacity 150ms;
}
.task-card:active { cursor: grabbing; }
.task-card.dragging { opacity: 0.5; }
.kanban-column.drag-over { background: #eff6ff; }What Interviewers Look For
- Data modeling ā Flat task array with
columnIdandorder, not nested column arrays - Reducer pattern ā Complex state transitions (move with reorder) handled atomically
- Drag & Drop API ā
dataTransfer,dragOverwithpreventDefault, drop index calculation - Reorder logic ā Correct splice + reindex when moving between and within columns
- UX polish ā Visual drag feedback, drop zone highlighting, smooth transitions