FossilsšŸ› ļø Machine CodingBuild a Kanban Board with Drag & Drop
šŸ‘‘ApexMachine CodingDrag and DropComplex State

Build a Kanban Board with Drag & Drop

A Kanban board tests complex state management, drag-and-drop APIs, optimistic updates, and multi-column data modeling. One of the hardest machine coding challenges.

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

  1. Data modeling — Flat task array with columnId and order, not nested column arrays
  2. Reducer pattern — Complex state transitions (move with reorder) handled atomically
  3. Drag & Drop API — dataTransfer, dragOver with preventDefault, drop index calculation
  4. Reorder logic — Correct splice + reindex when moving between and within columns
  5. UX polish — Visual drag feedback, drop zone highlighting, smooth transitions