Build a Kanban Board With React
SkillVeris Team
Engineering Team

A React Kanban board renders columns (To Do, In Progress, Done) of draggable cards you can move between lists to track work.
In this guide, you'll learn:
- Model the board as a single state object of columns and cards, and update it immutably when cards move.
- Lift the board state to a parent component so columns and cards stay in sync.
- Use the HTML Drag and Drop API or a library like dnd-kit to handle moving cards.
- Persist the board to localStorage so it survives a refresh.
1What You Are Building
A Kanban board is a visual task manager: cards representing tasks live in columns like To Do, In Progress, and Done, and you drag cards between columns as work progresses. In React you build it from composable components with a shared state that describes every column and card.
It is a fantastic intermediate project because it forces you to think carefully about state shape, immutable updates, and how data flows between components. Add drag-and-drop and persistence, and you have a genuinely useful app that demonstrates the patterns behind most React applications.
2Planning the Component Structure
Good React apps start with a clear component tree. A Kanban board decomposes naturally: a Board holds Columns, each Column holds Cards. Keeping components small and focused makes the app easier to reason about and extend.
The Board owns the data and passes slices down as props; Columns and Cards are largely presentational, rendering what they receive and calling callbacks when the user acts. This top-down flow is the heart of the React mental model.
- Board: owns the full state and orchestrates moves.
- Column: receives its title and its list of cards as props.
- Card: renders a single task and its drag handlers.
- AddCard: a small form to create new tasks in a column.
- Keep each component in its own file for clarity.
3Modeling the Board State
The most important decision is the shape of your state. A clean model stores columns keyed by id, each with an ordered list of card ids, plus a separate lookup of cards by id. This normalized shape makes moving a card a matter of editing two arrays.
Storing an ordered array of ids per column preserves card order, while the separate card lookup avoids duplicating card data. Reaching for a normalized structure early prevents tangled updates later.
A Normalized Shape
Columns hold ordered card ids; a cards map holds the content.
const initial = {
columns: {
todo: { title: 'To Do', cardIds: ['c1','c2'] },
doing: { title: 'In Progress', cardIds: [] },
done: { title: 'Done', cardIds: [] }
},
cards: { c1: { text: 'Design UI' }, c2: { text: 'Write tests' } }
};4Lifting State to the Board
Because moving a card affects two columns, no single Column can own the data — the shared state must live in their common parent, the Board. This is React's 'lifting state up' principle: put state where the components that need it can all reach it.
The Board holds the state with useState (or useReducer for complex logic) and passes down both the data each Column needs and the functions that update it. Columns never mutate state directly; they invoke callbacks, and the Board produces a new state.
🔑Key Idea
When two components need to share or change the same data, move that state up to their nearest common ancestor. Trying to keep independent copies in each column leads to bugs where columns disagree about reality.
5Adding Drag and Drop
Dragging cards between columns is the signature interaction. You have two paths: the native HTML Drag and Drop API, which works but is fiddly, or a library like dnd-kit or react-beautiful-dnd that handles accessibility and edge cases for you. For a learning project, try the native API first, then adopt a library for polish.
However you capture the drop, the state update is the same: remove the card id from the source column's array and insert it into the target column's array — always producing new arrays rather than mutating the old ones.
- onDragStart: store the dragged card's id and source column.
- onDragOver: call preventDefault so the drop is allowed.
- onDrop: read the card id and move it to the target column.
- Update state immutably — create new arrays, do not mutate.
- For accessibility and touch support, prefer dnd-kit.
6Updating State Immutably
React decides what to re-render by comparing references, so you must never mutate state in place. When a card moves, build new column arrays and a new state object rather than pushing into or splicing the existing arrays.
Use spread syntax and array methods like filter to create fresh copies. This discipline is what makes React predictable — mutating state directly often produces UIs that mysteriously fail to update.
Moving a Card Immutably
Filter the id out of the source list and append it to the target list, returning a new state.
setBoard(prev => ({
...prev,
columns: {
...prev.columns,
[from]: { ...prev.columns[from], cardIds: prev.columns[from].cardIds.filter(id => id !== cardId) },
[to]: { ...prev.columns[to], cardIds: [...prev.columns[to].cardIds, cardId] }
}
}));7Persisting the Board
A task board is useless if it forgets everything on refresh. Save the board state to localStorage whenever it changes with a useEffect that runs on state updates, and load it once when the app mounts.
Serialize with JSON.stringify to store it and JSON.parse to restore it. For a single-user local tool this is all you need; a multi-user version would sync to a backend instead.
- useEffect(() => localStorage.setItem('board', JSON.stringify(board)), [board]);
- const [board, setBoard] = useState(() => {
- const saved = localStorage.getItem('board');
- return saved ? JSON.parse(saved) : initial;
- });
8Common Mistakes to Avoid
A handful of issues catch React beginners building interactive apps.
- Mutating state directly instead of creating new objects and arrays.
- Keeping duplicate card data in each column, so columns drift out of sync.
- Forgetting preventDefault in onDragOver, which silently blocks drops.
- Missing or non-unique key props on mapped cards, causing render glitches.
- Storing state in child columns instead of lifting it to the Board.
9Key Takeaways
The board distills several core React skills into one build.
- Decompose the UI into Board, Column, and Card components.
- Use a normalized state shape: columns of card ids plus a card lookup.
- Lift shared state to the Board so moves stay consistent.
- Update state immutably with spread syntax — never mutate in place.
- Persist to localStorage so the board survives a refresh.
10Frequently Asked Questions
Q: Should I use a drag-and-drop library or the native API? A: The native HTML Drag and Drop API is great for learning and works without dependencies, but it is fiddly and weak on touch and accessibility. Libraries like dnd-kit handle those cases, so use one when you want a polished result.
Q: Why does my board not update when I move a card? A: You are most likely mutating state directly. React compares references to decide what to re-render, so pushing into an existing array will not trigger an update. Always create new arrays and objects.
Q: Where should the board state live? A: In the Board component, the common parent of the columns. Because a move affects two columns, no single column can own the data. This is React's lifting-state-up pattern.
Q: How do I make the board survive a refresh? A: Save it to localStorage with a useEffect that runs whenever the state changes, and initialize state from localStorage on mount. Serialize with JSON.stringify and restore with JSON.parse.
Related Reading
Get The Print Version
Download a PDF of this article for offline reading.
About the Publisher
SkillVeris Team
Engineering Team
Our engineering team documents real build journeys so you can learn by doing, not just reading.
View all postsRelated Posts
Never miss an update
Get the latest tutorials and guides delivered to your inbox.
No spam. Unsubscribe anytime.