10 · Project — Kanban Board¶
This project exercises the whole of Level 3 in one app: a Kanban board (think a
minimal Trello) with columns and cards, written in TypeScript. Cards live on a local
REST server and are managed with TanStack Query, including optimistic moves. UI-only
state (selected card, filters) lives in a small Zustand store. Cards can be moved by
drag and drop and by keyboard. The card editor is lazy-loaded inside a native
<dialog>, and failures are contained by error boundaries.
Features¶
- Three columns: To do, In progress, Done.
- Create, edit and delete cards; move them between columns.
- Drag and drop with the mouse; move with keyboard buttons as an accessible alternative.
- Filter by text and by label.
- Optimistic updates with rollback when the server fails.
- A lazy-loaded card editor dialog.
Setup¶
npm create vite@latest kanban -- --template react-ts
cd kanban
npm install @tanstack/react-query zustand react-error-boundary zod
npm install -D json-server
Create db.json at the project root:
{
"cards": [
{ "id": "1", "title": "Set up CI", "description": "", "column": "todo", "label": "ops", "order": 1 },
{ "id": "2", "title": "Design login screen", "description": "", "column": "doing", "label": "design", "order": 1 },
{ "id": "3", "title": "Write README", "description": "", "column": "done", "label": "docs", "order": 1 }
]
}
Add a script and run it in a second terminal:
json-server gives you GET/POST /cards and GET/PATCH/DELETE /cards/:id, persisted to
db.json. Its CLI flags have changed across major versions; if --watch is rejected,
run npx json-server db.json --port 3001 instead.
Step 1 — types and validation¶
// src/types.ts
import { z } from 'zod'
export const COLUMNS = ['todo', 'doing', 'done'] as const
export type ColumnId = (typeof COLUMNS)[number]
export const COLUMN_TITLES: Record<ColumnId, string> = { todo: 'To do', doing: 'In progress', done: 'Done' }
export const CardSchema = z.object({
id: z.string(),
title: z.string().min(1),
description: z.string(),
column: z.enum(COLUMNS),
label: z.string(),
order: z.number(),
})
export type Card = z.infer<typeof CardSchema>
export type NewCard = Omit<Card, 'id'>
The runtime schema and the static type come from one definition, so the network boundary is checked, not just assumed.
Step 2 — API functions¶
// src/api.ts
import { z } from 'zod'
import { CardSchema, type Card, type NewCard } from './types'
const BASE = 'http://localhost:3001'
async function request<T>(path: string, schema: z.ZodType<T>, init?: RequestInit): Promise<T> {
const res = await fetch(`${BASE}${path}`, {
...init,
headers: { 'Content-Type': 'application/json', ...init?.headers },
})
if (!res.ok) throw new Error(`${init?.method ?? 'GET'} ${path} failed: ${res.status}`)
return schema.parse(await res.json())
}
export const getCards = (signal?: AbortSignal) => request('/cards', z.array(CardSchema), { signal })
export const createCard = (card: NewCard) =>
request('/cards', CardSchema, { method: 'POST', body: JSON.stringify(card) })
export const updateCard = (id: string, patch: Partial<NewCard>) =>
request(`/cards/${id}`, CardSchema, { method: 'PATCH', body: JSON.stringify(patch) })
export const deleteCard = async (id: string) => {
const res = await fetch(`${BASE}/cards/${id}`, { method: 'DELETE' })
if (!res.ok) throw new Error(`DELETE failed: ${res.status}`)
}
Step 3 — query hooks with optimistic moves¶
// src/useCards.ts
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import * as api from './api'
import type { Card, ColumnId } from './types'
const key = ['cards'] as const
export function useCards() {
return useQuery({ queryKey: key, queryFn: ({ signal }) => api.getCards(signal) })
}
export function useMoveCard() {
const qc = useQueryClient()
return useMutation({
mutationFn: ({ id, column, order }: { id: string; column: ColumnId; order: number }) =>
api.updateCard(id, { column, order }),
onMutate: async ({ id, column, order }) => {
await qc.cancelQueries({ queryKey: key })
const previous = qc.getQueryData<Card[]>(key)
qc.setQueryData<Card[]>(key, old =>
old?.map(c => (c.id === id ? { ...c, column, order } : c)),
)
return { previous }
},
onError: (_e, _vars, ctx) => {
if (ctx?.previous) qc.setQueryData(key, ctx.previous)
},
onSettled: () => qc.invalidateQueries({ queryKey: key }),
})
}
export function useCreateCard() {
const qc = useQueryClient()
return useMutation({
mutationFn: api.createCard,
onSuccess: () => qc.invalidateQueries({ queryKey: key }),
})
}
export function useUpdateCard() {
const qc = useQueryClient()
return useMutation({
mutationFn: ({ id, patch }: { id: string; patch: Partial<Omit<Card, 'id'>> }) => api.updateCard(id, patch),
onSuccess: () => qc.invalidateQueries({ queryKey: key }),
})
}
export function useDeleteCard() {
const qc = useQueryClient()
return useMutation({
mutationFn: api.deleteCard,
onSuccess: () => qc.invalidateQueries({ queryKey: key }),
})
}
Step 4 — UI state in a store¶
// src/uiStore.ts
import { create } from 'zustand'
type UiState = {
query: string
label: string | 'all'
editingId: string | null
setQuery: (q: string) => void
setLabel: (l: string | 'all') => void
openEditor: (id: string) => void
closeEditor: () => void
}
export const useUi = create<UiState>(set => ({
query: '',
label: 'all',
editingId: null,
setQuery: query => set({ query }),
setLabel: label => set({ label }),
openEditor: id => set({ editingId: id }),
closeEditor: () => set({ editingId: null }),
}))
Server data is not in this store — only UI state that exists nowhere else.
Step 5 — card and column components¶
// src/CardItem.tsx
import { COLUMNS, type Card, type ColumnId } from './types'
import { useUi } from './uiStore'
type Props = { card: Card; onMove: (id: string, column: ColumnId) => void }
export function CardItem({ card, onMove }: Props) {
const openEditor = useUi(s => s.openEditor)
const index = COLUMNS.indexOf(card.column)
const prev = COLUMNS[index - 1]
const next = COLUMNS[index + 1]
return (
<li
className="card"
draggable
onDragStart={e => {
e.dataTransfer.setData('text/plain', card.id)
e.dataTransfer.effectAllowed = 'move'
}}
>
<button className="card__title" onClick={() => openEditor(card.id)}>{card.title}</button>
{card.label && <span className={`label label--${card.label}`}>{card.label}</span>}
<div className="card__moves">
{prev && (
<button onClick={() => onMove(card.id, prev)} aria-label={`Move "${card.title}" to ${prev}`}>←</button>
)}
{next && (
<button onClick={() => onMove(card.id, next)} aria-label={`Move "${card.title}" to ${next}`}>→</button>
)}
</div>
</li>
)
}
HTML5 drag and drop isn't keyboard- or touch-accessible, so the ← / → buttons provide an equivalent path for everyone. (Libraries such as dnd-kit implement keyboard and touch dragging if you want richer interactions.)
// src/Column.tsx
import { useState } from 'react'
import { COLUMN_TITLES, type Card, type ColumnId } from './types'
import { CardItem } from './CardItem'
type Props = { id: ColumnId; cards: Card[]; onMove: (id: string, column: ColumnId) => void }
export function Column({ id, cards, onMove }: Props) {
const [over, setOver] = useState(false)
const headingId = `col-${id}`
return (
<section
className={over ? 'column column--over' : 'column'}
aria-labelledby={headingId}
onDragOver={e => {
e.preventDefault() // required to allow dropping
setOver(true)
}}
onDragLeave={() => setOver(false)}
onDrop={e => {
e.preventDefault()
setOver(false)
const cardId = e.dataTransfer.getData('text/plain')
if (cardId) onMove(cardId, id)
}}
>
<h2 id={headingId}>
{COLUMN_TITLES[id]} <span className="count">{cards.length}</span>
</h2>
<ul>
{cards.map(c => <CardItem key={c.id} card={c} onMove={onMove} />)}
</ul>
</section>
)
}
Column ids are fixed and unique on the page, so plain string ids are fine here.
Step 6 — the board¶
// src/Board.tsx
import { lazy, Suspense, useMemo } from 'react'
import { COLUMNS, type ColumnId } from './types'
import { useCards, useMoveCard } from './useCards'
import { useUi } from './uiStore'
import { Column } from './Column'
import { NewCardForm } from './NewCardForm'
const CardEditor = lazy(() => import('./CardEditor'))
export function Board() {
const { data: cards, isPending, isError, error, refetch } = useCards()
const move = useMoveCard()
const query = useUi(s => s.query)
const label = useUi(s => s.label)
const editingId = useUi(s => s.editingId)
const byColumn = useMemo(() => {
const q = query.toLowerCase()
const visible = (cards ?? []).filter(
c => (label === 'all' || c.label === label) && c.title.toLowerCase().includes(q),
)
const groups: Record<ColumnId, typeof visible> = { todo: [], doing: [], done: [] }
for (const c of visible) groups[c.column].push(c)
for (const col of COLUMNS) groups[col].sort((a, b) => a.order - b.order)
return groups
}, [cards, query, label])
if (isPending) return <p aria-busy="true">Loading board…</p>
if (isError) return <p role="alert">{error.message} <button onClick={() => refetch()}>Retry</button></p>
function handleMove(id: string, column: ColumnId) {
const card = cards!.find(c => c.id === id)
if (!card || card.column === column) return
const maxOrder = Math.max(0, ...cards!.filter(c => c.column === column).map(c => c.order))
move.mutate({ id, column, order: maxOrder + 1 })
}
return (
<>
{move.isError && <p role="alert">Couldn't move the card — it was put back.</p>}
<NewCardForm />
<div className="board">
{COLUMNS.map(col => <Column key={col} id={col} cards={byColumn[col]} onMove={handleMove} />)}
</div>
{editingId && (
<Suspense fallback={null}>
<CardEditor cardId={editingId} />
</Suspense>
)}
</>
)
}
The groups arrays are built fresh inside useMemo, so sorting them in place doesn't
touch cached query data. cards! is safe inside handleMove because it's only reachable
after the isPending/isError returns.
Step 7 — lazy editor in a native dialog¶
// src/CardEditor.tsx
import { useEffect, useRef, useState } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import type { Card } from './types'
import { useDeleteCard, useUpdateCard } from './useCards'
import { useUi } from './uiStore'
export default function CardEditor({ cardId }: { cardId: string }) {
const card = useQueryClient().getQueryData<Card[]>(['cards'])?.find(c => c.id === cardId)
const close = useUi(s => s.closeEditor)
const update = useUpdateCard()
const remove = useDeleteCard()
const ref = useRef<HTMLDialogElement>(null)
const [title, setTitle] = useState(card?.title ?? '')
const [description, setDescription] = useState(card?.description ?? '')
useEffect(() => {
ref.current?.showModal()
}, [])
if (!card) return null
return (
<dialog ref={ref} onClose={close} aria-labelledby="editor-title">
<form
onSubmit={e => {
e.preventDefault()
update.mutate({ id: card.id, patch: { title: title.trim(), description } }, { onSuccess: close })
}}
>
<h2 id="editor-title">Edit card</h2>
<label>Title <input value={title} onChange={e => setTitle(e.target.value)} required /></label>
<label>Description <textarea value={description} onChange={e => setDescription(e.target.value)} /></label>
{update.isError && <p role="alert">Save failed.</p>}
<button type="submit" disabled={update.isPending}>Save</button>
<button type="button" onClick={close}>Cancel</button>
<button type="button" className="danger" onClick={() => remove.mutate(card.id, { onSuccess: close })}>
Delete
</button>
</form>
</dialog>
)
}
Because Board only renders CardEditor while editingId is set, closing unmounts it
(and the dialog with it). The editor's code isn't downloaded until the first card is
opened.
Step 8 — filter bar, new card form, app shell¶
// src/NewCardForm.tsx
import { useState } from 'react'
import { useCreateCard } from './useCards'
import { useUi } from './uiStore'
export function NewCardForm() {
const create = useCreateCard()
const [title, setTitle] = useState('')
const query = useUi(s => s.query)
const setQuery = useUi(s => s.setQuery)
const label = useUi(s => s.label)
const setLabel = useUi(s => s.setLabel)
return (
<div className="toolbar">
<form
onSubmit={e => {
e.preventDefault()
if (!title.trim()) return
create.mutate(
{ title: title.trim(), description: '', column: 'todo', label: 'ops', order: Date.now() },
{ onSuccess: () => setTitle('') },
)
}}
>
<label>New card <input value={title} onChange={e => setTitle(e.target.value)} /></label>
<button disabled={create.isPending}>Add</button>
</form>
<label>Search <input type="search" value={query} onChange={e => setQuery(e.target.value)} /></label>
<label>
Label
<select value={label} onChange={e => setLabel(e.target.value)}>
<option value="all">All</option>
<option value="ops">ops</option>
<option value="design">design</option>
<option value="docs">docs</option>
</select>
</label>
</div>
)
}
// src/main.tsx
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { ErrorBoundary } from 'react-error-boundary'
import { Board } from './Board'
import './index.css'
const queryClient = new QueryClient({ defaultOptions: { queries: { staleTime: 10_000 } } })
createRoot(document.getElementById('root')!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<ErrorBoundary fallbackRender={({ error }) => <p role="alert">The board crashed: {String(error)}</p>}>
<main>
<h1>Kanban</h1>
<Board />
</main>
</ErrorBoundary>
</QueryClientProvider>
</StrictMode>,
)
Styling is left to you; a three-column CSS grid with .column--over highlighting is
enough.
How It Actually Works¶
Trace a drag from "To do" to "Done":
dragstarton the card stores its id in theDataTransferobject — browser-owned state outside React.- Hovering the Done column fires
dragoverrepeatedly; callingpreventDefault()tells the browser this is a valid drop target.setOver(true)re-renders only that column (its state is local), and repeatedsetOver(true)calls bail out because the value is unchanged. dropreads the id and callshandleMove, which callsmove.mutate.onMutatecancels in-flight card fetches (so an older response can't overwrite the optimistic state), snapshots the cache, and writes the moved card into it. The['cards']query's observers are notified viauseSyncExternalStore;Boardre-renders,useMemoregroups becausecardsis a new array, and eachColumnreconciles its list by key. The card now belongs to a differentColumn's<ul>, so React unmounts itsCardItemfrom the old list and mounts a fresh one in the new list — keys only preserve identity among siblings of the same parent, never across parents. Any local state insideCardItemwould reset on a move, which is one reason it has none.- The PATCH completes.
onSettledinvalidates, and a background refetch reconciles the cache with the server's truth. If the PATCH failed,onErrorrestores the snapshot first.
Meanwhile, typing in the search box updates the Zustand store; NewCardForm and Board
(the subscribers to query) re-render, but no network request happens, because
filtering is derived from cached data.
Exercise — extend it¶
- Support reordering within a column by dropping onto a card (compute an
orderbetween its neighbours), with keyboard "move up/down" equivalents. - Add a label field to the editor and a dynamic label list derived from the data.
- Write RTL tests for: moving a card with the keyboard buttons (mock
fetch); rollback when the PATCH returns 500. - Replace native DnD with dnd-kit and compare the accessibility of both approaches with a screen reader.
- Measure the bundle before and after the lazy editor with the visualizer from lesson 7.