ESC

Type to search the knowledge base.

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
  • localStorage persistence

Planning (5 minutes out loud)

  1. Normalize — columns: { id, title, cardIds }[] + cards: Record<id, Card>
  2. Single move function — (cardId, toColumnId, toIndex)
  3. DnD payload — cardId + fromColumnId
  4. MVP — render + add + select-column move buttons; then drag
  5. 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 Card on card props
  • Heavy boards: virtualize long columns independently

Footguns

  1. Duplicating card objects per column — desync titles
  2. Off-by-one when reordering in the same list
  3. Drop on empty column without droppable area height
  4. Using array index as key
  5. Mutating cardIds arrays in place inside reducer

Interview out-loud answer

State is normalized: columns hold ordered card ids, cards live in a map. One MOVE_CARD action 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.

Further reading