Project: Kanban Board
A kanban board is the project that turns abstract state-management lessons into something tactile: you literally drag a card from one column to another and watch the data follow. Building one forces you to design a normalized data model, perform immutable updates across nested structures, wire up drag-and-drop, and persist everything so the board survives a refresh. By the end you will have a working Trello-style board and a solid grip on how React reconciles list reordering with stable keys.
What we are building
A board with three columns—To Do, In Progress, and Done—where each column holds an ordered list of cards. You can drag a card within a column to reorder it or across columns to change its status, add new cards, and delete them. The entire board state is mirrored to localStorage. Scaffold a fresh project with Vite:
npm create vite@latest kanban -- --template react
cd kanban
npm install
npm run dev
Designing the data model
The single most important decision is how to shape state. A naive approach nests cards inside columns, but that makes moving a card a deep, error-prone splice. Instead we normalize: store cards in a lookup keyed by id, and have each column hold only an ordered array of card ids. Moving a card then becomes pure array manipulation—no card objects are copied.
export const initialBoard = {
cards: {
"c1": { id: "c1", text: "Set up Vite project" },
"c2": { id: "c2", text: "Design data model" },
"c3": { id: "c3", text: "Wire up drag and drop" },
"c4": { id: "c4", text: "Ship it" },
},
columns: {
todo: { id: "todo", title: "To Do", cardIds: ["c1", "c2"] },
doing: { id: "doing", title: "In Progress", cardIds: ["c3"] },
done: { id: "done", title: "Done", cardIds: ["c4"] },
},
columnOrder: ["todo", "doing", "done"],
};
Why normalize? With ids in arrays and card bodies in a flat map, a move touches at most two
cardIdsarrays. The card object itself never changes, so React can keep its DOM node and you avoid subtle bugs from cloning nested data.
Choosing a drag-and-drop approach
You have two realistic options. The native HTML5 Drag and Drop API ships with the browser and needs no dependency, but its event model is fiddly and accessibility is poor. A library like @dnd-kit/core gives you pointer + keyboard support, collision detection, and smooth sensors out of the box. For anything beyond a demo, reach for a library.
| Approach | Bundle cost | Keyboard a11y | Best for |
|---|---|---|---|
| HTML5 DnD | 0 KB | Poor | Quick demos, learning |
@dnd-kit | ~10 KB | Excellent | Production boards |
react-beautiful-dnd | ~30 KB | Good | Maintenance-mode, avoid for new apps |
We will use the native API so the code is fully self-contained, then note the @dnd-kit upgrade path.
The move logic
This is the heart of the app. Given a card id, the source column, and a destination column plus index, we produce a brand-new state object. We remove the id from the source array and insert it into the destination array immutably. When source and destination are the same column, the splice-then-insert handles reordering for free.
export function moveCard(board, cardId, fromColId, toColId, toIndex) {
const fromIds = board.columns[fromColId].cardIds.filter((id) => id !== cardId);
// If moving within the same column, work off the already-filtered array.
const baseIds = fromColId === toColId ? fromIds : board.columns[toColId].cardIds;
const toIds = [...baseIds];
toIds.splice(toIndex, 0, cardId);
return {
...board,
columns: {
...board.columns,
[fromColId]: { ...board.columns[fromColId], cardIds: fromIds },
[toColId]: { ...board.columns[toColId], cardIds: toIds },
},
};
}
Building the components
App owns the board, persists it, and exposes handlers. Each Column renders its cards and acts as a drop target; each Card is draggable and reports where it is being dragged from.
import { useEffect, useState } from "react";
import { initialBoard } from "./board";
import { moveCard } from "./moveCard";
import Column from "./Column";
const KEY = "kanban.v1";
export default function App() {
const [board, setBoard] = useState(() => {
const saved = localStorage.getItem(KEY);
return saved ? JSON.parse(saved) : initialBoard;
});
useEffect(() => {
localStorage.setItem(KEY, JSON.stringify(board));
}, [board]);
function handleDrop(cardId, fromColId, toColId, toIndex) {
setBoard((b) => moveCard(b, cardId, fromColId, toColId, toIndex));
}
function addCard(colId, text) {
const id = crypto.randomUUID();
setBoard((b) => ({
...b,
cards: { ...b.cards, [id]: { id, text } },
columns: {
...b.columns,
[colId]: { ...b.columns[colId], cardIds: [...b.columns[colId].cardIds, id] },
},
}));
}
return (
<div className="board">
{board.columnOrder.map((colId) => (
<Column
key={colId}
column={board.columns[colId]}
cards={board.cards}
onDrop={handleDrop}
onAdd={addCard}
/>
))}
</div>
);
}
The Column and Card components hook into the native DnD events. A card serializes its identity into the dataTransfer payload on dragStart; the column reads it back on drop.
export default function Column({ column, cards, onDrop, onAdd }) {
function handleDrop(e) {
e.preventDefault();
const { cardId, fromColId } = JSON.parse(e.dataTransfer.getData("application/json"));
onDrop(cardId, fromColId, column.id, column.cardIds.length);
}
return (
<section
className="column"
onDragOver={(e) => e.preventDefault()}
onDrop={handleDrop}
>
<h2>{column.title}</h2>
{column.cardIds.map((id) => (
<article
key={id}
className="card"
draggable
onDragStart={(e) =>
e.dataTransfer.setData(
"application/json",
JSON.stringify({ cardId: id, fromColId: column.id })
)
}
>
{cards[id].text}
</article>
))}
<button onClick={() => onAdd(column.id, "New card")}>+ Add card</button>
</section>
);
}
Dragging the “Ship it” card from Done back to To Do logs no errors and produces the expected reshuffle:
Output:
Drop -> cardId: c4 from: done to: todo index: 2
todo.cardIds = ["c1", "c2", "c4"]
done.cardIds = []
Gotcha:
onDragOvermust calle.preventDefault(), otherwise the browser refuses to fireonDrop. This is the number-one reason native DnD “silently does nothing.”
Upgrading to @dnd-kit
When you outgrow the native API, wrap the board in a DndContext, make each column a useDroppable and each card a useDraggable (or use SortableContext for in-column ordering). Your moveCard reducer stays exactly the same—only the event source changes from onDrop to onDragEnd. Keeping move logic pure and decoupled from the DnD library is what makes that swap painless.
Best practices
- Normalize state into a
cardsmap pluscardIdsarrays so moves are cheap, immutable array operations. - Use stable, unique card ids (e.g.
crypto.randomUUID()) as React keys—never the array index, which breaks reorder animations. - Keep all move/reorder logic in pure functions so it is unit-testable and DnD-library agnostic.
- Always
preventDefaultinonDragOverfor the native API, or drops never fire. - Persist with a versioned key like
kanban.v1so you can migrate the schema later without crashing on stale data. - Reach for
@dnd-kitfor production to get keyboard and screen-reader support for free.