Skip to content
React projects 5 min read

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 cardIds arrays. 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.

ApproachBundle costKeyboard a11yBest for
HTML5 DnD0 KBPoorQuick demos, learning
@dnd-kit~10 KBExcellentProduction boards
react-beautiful-dnd~30 KBGoodMaintenance-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: onDragOver must call e.preventDefault(), otherwise the browser refuses to fire onDrop. 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 cards map plus cardIds arrays 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 preventDefault in onDragOver for the native API, or drops never fire.
  • Persist with a versioned key like kanban.v1 so you can migrate the schema later without crashing on stale data.
  • Reach for @dnd-kit for production to get keyboard and screen-reader support for free.
Last updated June 14, 2026
Was this helpful?