On this page
Final Project: Kanban Task Board
What You Will Build
In this final project, you will build a Kanban task management board that puts into practice everything learned throughout the course:
- Components and composition — TaskCard, KanbanColumn, AddTaskForm, FilterBar
- useState — form field state, toggle for "adding" state
- useId — accessible, unique IDs for form elements
- useCallback — stable move/delete action references
- Zustand — persisted global store for tasks and filter state
- TypeScript — strict types for tasks, columns, priorities, and store
Feature Overview
The Kanban board includes:
- 4 columns: Backlog, In Progress, Review, Done
- Task cards with title, description, priority badge, and tags
- Move between columns with Prev/Next buttons (keyboard accessible)
- Delete tasks with accessible button on each card
- Add tasks per-column with inline form (title, description, priority)
- Filter tasks by priority and search query
- Persistent state via Zustand
persistmiddleware (survives page refresh)
Architecture Decisions
Why Zustand instead of useState + Context?
The task list and filter state are consumed by multiple sibling components (FilterBar, KanbanColumn × 4, TaskCard × N). Context would cause all consumers to re-render on any state change. Zustand's selector-based subscriptions ensure each component only re-renders when its specific slice changes.
Why useId for form inputs?
The useId hook generates a unique, stable ID for each form instance, even when multiple AddTaskForm components are mounted simultaneously (one per column). This prevents duplicate IDs in the DOM, which would break label association.
Why role and aria-label?
Every interactive region has explicit ARIA attributes:
role="list"on task containersrole="listitem"on each task card wrapperaria-labelon all icon-only buttonsaria-labelon section and main elements
This ensures the board is fully usable by keyboard-only users and screen reader users.
Extending the Project
Once you complete the base project, try these challenges:
Add Drag and Drop
Install @dnd-kit/core:
npm install @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilitiesWrap the board with DndContext and handle the onDragEnd event to call moveTask with the destination column ID.
Add Task Editing
Add an "Edit" button to each TaskCard that opens an inline form pre-populated with the task's current values. Use updateTask from the store to persist changes.
Add Column Statistics
Below each column header, show stats computed with useMemo:
- Number of high-priority tasks
- Average age of tasks in days
- Completion percentage (done vs total)
Connect to a Real Backend
Replace Zustand's localStorage persistence with API calls:
// On mount — load tasks from API
const { data: remoteTasks } = useQuery({
queryKey: ['tasks'],
queryFn: () => fetch('/api/tasks').then((r) => r.json()),
});
// On mutation — sync to API
const moveMutation = useMutation({
mutationFn: ({ taskId, columnId }: { taskId: string; columnId: ColumnId }) =>
fetch(`/api/tasks/${taskId}`, {
method: 'PATCH',
body: JSON.stringify({ columnId }),
headers: { 'Content-Type': 'application/json' },
}),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['tasks'] }),
});Write Tests
Test each component and the Zustand store in isolation:
// Test the store
it('moves a task to a new column', () => {
const store = useKanbanStore.getState();
const taskId = store.tasks[0].id;
store.moveTask(taskId, 'done');
expect(useKanbanStore.getState().tasks.find((t) => t.id === taskId)?.columnId).toBe('done');
});
// Test a component
it('renders task card with correct priority badge', () => {
render(<TaskCard task={mockTask} />);
expect(screen.getByText('high')).toBeInTheDocument();
});Congratulations
You have completed the React for Developers course. You now have a solid foundation in:
- JSX and functional components
- Props, children, and component composition
- useState, useEffect, useRef, useMemo, useCallback
- Custom hooks for reusable stateful logic
- Context API for cross-component state
- React Router v7 for client-side navigation
- Controlled forms and React 19 actions
- TanStack Query for server state management
- Suspense and Error Boundaries
- Zustand for global client state
- Performance optimization and the React Compiler
- Testing with Vitest and Testing Library
The next step is to apply these skills in a real full-stack project using Next.js 15 with the App Router, React Server Components, and database integration. Happy coding!
import { useState, useCallback, useId } from 'react';
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
// ─── Types ────────────────────────────────────────────────────
type Priority = 'low' | 'medium' | 'high';
type ColumnId = 'backlog' | 'in-progress' | 'review' | 'done';
interface Task {
id: string;
title: string;
description: string;
priority: Priority;
columnId: ColumnId;
createdAt: number;
tags: string[];
}
interface Column {
id: ColumnId;
title: string;
color: string;
}
const COLUMNS: Column[] = [
{ id: 'backlog', title: 'Backlog', color: '#6b7280' },
{ id: 'in-progress', title: 'In Progress', color: '#3b82f6' },
{ id: 'review', title: 'Review', color: '#f59e0b' },
{ id: 'done', title: 'Done', color: '#22c55e' },
];
const SEED_TASKS: Task[] = [
{
id: 'task-1',
title: 'Set up React project',
description: 'Initialize Vite + TypeScript + Zustand',
priority: 'high',
columnId: 'done',
createdAt: Date.now() - 86400000,
tags: ['setup', 'devops'],
},
{
id: 'task-2',
title: 'Design Kanban board layout',
description: 'Wireframe the board UI in Figma',
priority: 'medium',
columnId: 'done',
createdAt: Date.now() - 72000000,
tags: ['design'],
},
{
id: 'task-3',
title: 'Implement drag and drop',
description: 'Use @dnd-kit/core for drag-and-drop between columns',
priority: 'high',
columnId: 'in-progress',
createdAt: Date.now() - 36000000,
tags: ['feature'],
},
{
id: 'task-4',
title: 'Add task filtering',
description: 'Filter by priority, tags, and search query',
priority: 'medium',
columnId: 'backlog',
createdAt: Date.now() - 18000000,
tags: ['feature', 'ux'],
},
{
id: 'task-5',
title: 'Write unit tests',
description: 'Test store actions and TaskCard component',
priority: 'low',
columnId: 'backlog',
createdAt: Date.now() - 7200000,
tags: ['testing'],
},
];
// ─── Zustand Store ────────────────────────────────────────────
interface KanbanStore {
tasks: Task[];
filter: { priority: Priority | 'all'; search: string };
addTask: (task: Omit<Task, 'id' | 'createdAt'>) => void;
moveTask: (taskId: string, targetColumnId: ColumnId) => void;
deleteTask: (taskId: string) => void;
updateTask: (taskId: string, updates: Partial<Task>) => void;
setFilter: (filter: Partial<KanbanStore['filter']>) => void;
}
const useKanbanStore = create<KanbanStore>()(
persist(
(set) => ({
tasks: SEED_TASKS,
filter: { priority: 'all', search: '' },
addTask: (taskData) =>
set((s) => ({
tasks: [
...s.tasks,
{
...taskData,
id: `task-${Date.now()}`,
createdAt: Date.now(),
},
],
})),
moveTask: (taskId, targetColumnId) =>
set((s) => ({
tasks: s.tasks.map((t) =>
t.id === taskId ? { ...t, columnId: targetColumnId } : t
),
})),
deleteTask: (taskId) =>
set((s) => ({
tasks: s.tasks.filter((t) => t.id !== taskId),
})),
updateTask: (taskId, updates) =>
set((s) => ({
tasks: s.tasks.map((t) =>
t.id === taskId ? { ...t, ...updates } : t
),
})),
setFilter: (filter) =>
set((s) => ({ filter: { ...s.filter, ...filter } })),
}),
{ name: 'kanban-board' }
)
);
// ─── Selectors ────────────────────────────────────────────────
function useFilteredTasksByColumn(columnId: ColumnId) {
return useKanbanStore((s) => {
const { priority, search } = s.filter;
return s.tasks.filter(
(t) =>
t.columnId === columnId &&
(priority === 'all' || t.priority === priority) &&
(search === '' || t.title.toLowerCase().includes(search.toLowerCase()))
);
});
}
// ─── Priority Badge ────────────────────────────────────────────
const PRIORITY_COLORS: Record<Priority, string> = {
low: '#22c55e',
medium: '#f59e0b',
high: '#ef4444',
};
function PriorityBadge({ priority }: { priority: Priority }) {
return (
<span
style={{
backgroundColor: PRIORITY_COLORS[priority],
color: '#fff',
padding: '2px 8px',
borderRadius: '9999px',
fontSize: '0.7rem',
fontWeight: 700,
textTransform: 'uppercase',
letterSpacing: '0.05em',
}}
>
{priority}
</span>
);
}
// ─── Task Card ────────────────────────────────────────────────
interface TaskCardProps {
task: Task;
}
function TaskCard({ task }: TaskCardProps) {
const deleteTask = useKanbanStore((s) => s.deleteTask);
const moveTask = useKanbanStore((s) => s.moveTask);
const currentIndex = COLUMNS.findIndex((c) => c.id === task.columnId);
function movePrev() {
if (currentIndex > 0) moveTask(task.id, COLUMNS[currentIndex - 1].id);
}
function moveNext() {
if (currentIndex < COLUMNS.length - 1)
moveTask(task.id, COLUMNS[currentIndex + 1].id);
}
return (
<article
className="task-card"
style={{
background: '#fff',
border: '1px solid #e5e7eb',
borderRadius: '8px',
padding: '12px',
display: 'flex',
flexDirection: 'column',
gap: '8px',
boxShadow: '0 1px 3px rgba(0,0,0,0.1)',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<h3 style={{ margin: 0, fontSize: '0.9rem', fontWeight: 600 }}>
{task.title}
</h3>
<PriorityBadge priority={task.priority} />
</div>
{task.description && (
<p style={{ margin: 0, fontSize: '0.8rem', color: '#6b7280' }}>
{task.description}
</p>
)}
{task.tags.length > 0 && (
<div style={{ display: 'flex', gap: '4px', flexWrap: 'wrap' }}>
{task.tags.map((tag) => (
<span
key={tag}
style={{
backgroundColor: '#f3f4f6',
padding: '2px 6px',
borderRadius: '4px',
fontSize: '0.7rem',
color: '#374151',
}}
>
#{tag}
</span>
))}
</div>
)}
<div style={{ display: 'flex', gap: '4px', marginTop: '4px' }}>
<button
type="button"
onClick={movePrev}
disabled={currentIndex === 0}
aria-label="Move to previous column"
style={{ flex: 1, fontSize: '0.75rem' }}
>
← Prev
</button>
<button
type="button"
onClick={moveNext}
disabled={currentIndex === COLUMNS.length - 1}
aria-label="Move to next column"
style={{ flex: 1, fontSize: '0.75rem' }}
>
Next →
</button>
<button
type="button"
onClick={() => deleteTask(task.id)}
aria-label={`Delete task: ${task.title}`}
style={{ fontSize: '0.75rem', color: '#ef4444' }}
>
✕
</button>
</div>
</article>
);
}
// ─── Add Task Form ─────────────────────────────────────────────
interface AddTaskFormProps {
columnId: ColumnId;
onClose: () => void;
}
function AddTaskForm({ columnId, onClose }: AddTaskFormProps) {
const addTask = useKanbanStore((s) => s.addTask);
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [priority, setPriority] = useState<Priority>('medium');
const titleId = useId();
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
if (!title.trim()) return;
addTask({
title: title.trim(),
description: description.trim(),
priority,
columnId,
tags: [],
});
onClose();
}
return (
<form
onSubmit={handleSubmit}
style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}
>
<label htmlFor={titleId} style={{ fontSize: '0.8rem', fontWeight: 600 }}>
Task title
</label>
<input
id={titleId}
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Enter task title..."
required
autoFocus
style={{ padding: '6px 8px', borderRadius: '6px', border: '1px solid #d1d5db' }}
/>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Description (optional)"
rows={2}
aria-label="Description"
style={{ padding: '6px 8px', borderRadius: '6px', border: '1px solid #d1d5db', resize: 'none' }}
/>
<select
value={priority}
onChange={(e) => setPriority(e.target.value as Priority)}
aria-label="Priority"
style={{ padding: '6px 8px', borderRadius: '6px', border: '1px solid #d1d5db' }}
>
<option value="low">Low priority</option>
<option value="medium">Medium priority</option>
<option value="high">High priority</option>
</select>
<div style={{ display: 'flex', gap: '8px' }}>
<button type="submit" style={{ flex: 1 }}>Add task</button>
<button type="button" onClick={onClose} style={{ flex: 1 }}>Cancel</button>
</div>
</form>
);
}
// ─── Kanban Column ─────────────────────────────────────────────
function KanbanColumn({ column }: { column: Column }) {
const tasks = useFilteredTasksByColumn(column.id);
const [isAdding, setIsAdding] = useState(false);
return (
<section
aria-label={column.title}
style={{
flex: '1 1 250px',
minWidth: '250px',
maxWidth: '320px',
backgroundColor: '#f9fafb',
borderRadius: '12px',
padding: '16px',
display: 'flex',
flexDirection: 'column',
gap: '12px',
}}
>
<header style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span
style={{
width: '10px',
height: '10px',
borderRadius: '50%',
backgroundColor: column.color,
flexShrink: 0,
}}
aria-hidden="true"
/>
<h2 style={{ margin: 0, fontSize: '0.95rem', fontWeight: 700 }}>
{column.title}
</h2>
<span
style={{
marginLeft: 'auto',
backgroundColor: '#e5e7eb',
borderRadius: '9999px',
padding: '2px 8px',
fontSize: '0.75rem',
fontWeight: 600,
}}
aria-label={`${tasks.length} tasks`}
>
{tasks.length}
</span>
</header>
<div
style={{ display: 'flex', flexDirection: 'column', gap: '8px', flex: 1 }}
role="list"
aria-label={`${column.title} tasks`}
>
{tasks.map((task) => (
<div key={task.id} role="listitem">
<TaskCard task={task} />
</div>
))}
</div>
{isAdding ? (
<AddTaskForm columnId={column.id} onClose={() => setIsAdding(false)} />
) : (
<button
type="button"
onClick={() => setIsAdding(true)}
aria-label={`Add task to ${column.title}`}
style={{
background: 'transparent',
border: '2px dashed #d1d5db',
borderRadius: '8px',
padding: '8px',
cursor: 'pointer',
color: '#9ca3af',
fontSize: '0.85rem',
transition: 'all 0.2s',
}}
>
+ Add task
</button>
)}
</section>
);
}
// ─── Filter Bar ────────────────────────────────────────────────
function FilterBar() {
const filter = useKanbanStore((s) => s.filter);
const setFilter = useKanbanStore((s) => s.setFilter);
const totalTasks = useKanbanStore((s) => s.tasks.length);
return (
<div style={{ display: 'flex', gap: '12px', alignItems: 'center', flexWrap: 'wrap' }}>
<input
type="search"
value={filter.search}
onChange={(e) => setFilter({ search: e.target.value })}
placeholder="Search tasks..."
aria-label="Search tasks"
style={{ padding: '8px 12px', borderRadius: '8px', border: '1px solid #d1d5db', minWidth: '200px' }}
/>
<label htmlFor="priority-filter" style={{ fontSize: '0.85rem', whiteSpace: 'nowrap' }}>
Priority:
</label>
<select
id="priority-filter"
value={filter.priority}
onChange={(e) => setFilter({ priority: e.target.value as Priority | 'all' })}
style={{ padding: '8px 12px', borderRadius: '8px', border: '1px solid #d1d5db' }}
>
<option value="all">All priorities</option>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
</select>
<span style={{ marginLeft: 'auto', fontSize: '0.85rem', color: '#6b7280' }}>
{totalTasks} total tasks
</span>
</div>
);
}
// ─── Main Board ────────────────────────────────────────────────
export default function KanbanBoard() {
return (
<div
style={{
fontFamily: 'system-ui, sans-serif',
padding: '24px',
maxWidth: '1400px',
margin: '0 auto',
minHeight: '100vh',
backgroundColor: '#f3f4f6',
}}
>
<header style={{ marginBottom: '24px' }}>
<h1 style={{ margin: '0 0 16px', fontSize: '1.5rem', fontWeight: 800 }}>
Kanban Board
</h1>
<FilterBar />
</header>
<main>
<div
style={{ display: 'flex', gap: '16px', overflowX: 'auto', paddingBottom: '16px' }}
role="region"
aria-label="Task columns"
>
{COLUMNS.map((col) => (
<KanbanColumn key={col.id} column={col} />
))}
</div>
</main>
</div>
);
}
Sign in to track your progress