Kanban Board
Machine-coding brief for a kanban board — columns, cards, cross-column drag, state model, and keyboard moves.
intermediate4 min read
- machine-coding
- interview
- react
- dnd
- state
Problem statement
Build a kanban board: columns (e.g. Todo / Doing / Done) each with ordered cards; move cards within and across columns. Interviewers score normalized state design and DnD index math more than visual polish.
Requirements
Must have
- Multiple columns with titles
- Cards with id + title (optional description)
- Add card to a column
- Move card between columns (drag or buttons)
- Reorder within a column
- Persist order in state (controlled or internal)
Should have
- Keyboard move: “Move to column” / up / down
- Empty column drop target
- Optimistic UI if wired to mock API
Nice to have
- Card modal detail
- WIP limits
- Multi-select drag
localStoragepersistence
Planning (5 minutes out loud)
- Normalize —
columns: { id, title, cardIds }[]+cards: Record<id, Card> - Single move function —
(cardId, toColumnId, toIndex) - DnD payload — cardId + fromColumnId
- MVP — render + add + select-column move buttons; then drag
- Keys stable by card id
Architecture
KanbanBoard
├── Column
│ ├── ColumnHeader
│ ├── CardList
│ │ └── Card
│ └── AddCardForm
└── boardState / reducer
Data model
type CardId = string;
type ColumnId = string;
type Card = {
id: CardId;
title: string;
};
type Column = {
id: ColumnId;
title: string;
cardIds: CardId[];
};
type BoardState = {
columns: Column[];
cards: Record<CardId, Card>;
};
type BoardAction =
| { type: "ADD_CARD"; columnId: ColumnId; title: string }
| { type: "MOVE_CARD"; cardId: CardId; toColumnId: ColumnId; toIndex: number }
| { type: "DELETE_CARD"; cardId: CardId };
Implementation sketch
Reducer move
function boardReducer(state: BoardState, action: BoardAction): BoardState {
switch (action.type) {
case "ADD_CARD": {
const id = crypto.randomUUID();
return {
cards: { ...state.cards, [id]: { id, title: action.title.trim() } },
columns: state.columns.map((c) =>
c.id === action.columnId ? { ...c, cardIds: [...c.cardIds, id] } : c
),
};
}
case "MOVE_CARD": {
const { cardId, toColumnId, toIndex } = action;
// remove from all columns first
let columns = state.columns.map((c) => ({
...c,
cardIds: c.cardIds.filter((id) => id !== cardId),
}));
columns = columns.map((c) => {
if (c.id !== toColumnId) return c;
const cardIds = c.cardIds.slice();
cardIds.splice(toIndex, 0, cardId);
return { ...c, cardIds };
});
return { ...state, columns };
}
default:
return state;
}
}
Same-column reorder: remove first, then insert — adjust toIndex if removing an earlier index shifts positions:
function moveCard(
state: BoardState,
cardId: CardId,
toColumnId: ColumnId,
toIndex: number
): BoardState {
const fromCol = state.columns.find((c) => c.cardIds.includes(cardId));
if (!fromCol) return state;
const fromIndex = fromCol.cardIds.indexOf(cardId);
let insertAt = toIndex;
if (fromCol.id === toColumnId && fromIndex < toIndex) {
insertAt = toIndex - 1;
}
// then remove + insert as above with insertAt
return boardReducer(state, {
type: "MOVE_CARD",
cardId,
toColumnId,
toIndex: insertAt,
});
}
HTML5 drop on column
function ColumnView({
column,
cards,
dispatch,
}: {
column: Column;
cards: Record<CardId, Card>;
dispatch: React.Dispatch<BoardAction>;
}) {
return (
<section
className="column"
aria-label={column.title}
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => {
const cardId = e.dataTransfer.getData("text/card-id");
if (!cardId) return;
dispatch({
type: "MOVE_CARD",
cardId,
toColumnId: column.id,
toIndex: column.cardIds.length, // append; refine with hit-test
});
}}
>
<h2>{column.title}</h2>
<ul>
{column.cardIds.map((id) => (
<li
key={id}
draggable
onDragStart={(e) => {
e.dataTransfer.setData("text/card-id", id);
e.dataTransfer.effectAllowed = "move";
}}
>
{cards[id]?.title}
</li>
))}
</ul>
</section>
);
}
Accessibility essentials
- Columns as regions with labels
- Keyboard: “Move left/right column” and “Move up/down” buttons on card menu
- Announce moves via
aria-live - Drag is enhancement; don’t make it the only path
Performance notes
- Normalized cards map avoids deep recursive updates
- Memo
Cardoncardprops - Heavy boards: virtualize long columns independently
Footguns
- Duplicating card objects per column — desync titles
- Off-by-one when reordering in the same list
- Drop on empty column without droppable area height
- Using array index as key
- Mutating
cardIdsarrays in place inside reducer
Interview out-loud answer
State is normalized: columns hold ordered card ids, cards live in a map. One
MOVE_CARDaction removes the id then inserts at a target index, with same-column index adjustment. MVP is add card + move via menu; HTML5 DnD between columns next. I’d call out a11y buttons and empty-column drop targets as polish.