On this page

Final Project: Kanban Task Board

25 min read TextCh. 5 — Production

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 persist middleware (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 containers
  • role="listitem" on each task card wrapper
  • aria-label on all icon-only buttons
  • aria-label on 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/utilities

Wrap 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!

Challenge 1 — Add drag and drop
Install `@dnd-kit/core` and `@dnd-kit/sortable` to add drag-and-drop between columns. Wrap the board with `DndContext`, each column with `SortableContext`, and each task card with `useSortable`. Call `moveTask` in the `onDragEnd` handler.
Challenge 2 — Add due dates and overdue detection
Add a `dueDate` field to the `Task` interface. Display a warning badge on cards where the due date has passed. Use a `useMemo` selector to compute overdue task count and show it in the board header.
Challenge 3 — Persist to a backend
Replace the Zustand `persist` middleware with real API calls. On mount, fetch tasks from `/api/tasks`. On add/move/delete, call the appropriate API mutation. Use TanStack Query to manage server state and Zustand only for UI state like the active filter.
tsx
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>
  );
}