B11 · Part 1 · Beginner
Guided project: a task board
Every beginner chapter, in one app built end to end.
11On this page
- What you are building
- Step 1 — the model, before any JSX
- Step 2 — static render from hardcoded data
- Step 3 — state, and where it lives
- Step 4 — adding, moving, deleting
- Step 5 — the form
- Step 6 — moving without drag and drop
- Step 7 — persistence
- Step 8 — the states you would otherwise forget
- Break it on purpose
- If you have time left
What you are building
A board with three columns, cards you can add, move and delete, a filter, and state that survives a refresh. It is also the most common machine-coding brief there is — if you can build this in ninety minutes you can pass that round.
Build in order. Each step ends with something that runs, so you always know which change broke it.
Step 1 — the model, before any JSX
const COLUMNS = ["todo", "doing", "done"];
// tasks flat and keyed by id — not nested inside columns
const initial = {
"a1": { id: "a1", title: "Write the brief", column: "todo" },
"b2": { id: "b2", title: "Review PR", column: "doing" },
};
This decision is the whole round
Nesting tasks inside columns —
{ todo: [...], doing: [...] } — makes a move a splice out
of one array and an insert into another, and "which column is this card in"
a scan of every column. Flat with a column field makes a move
one field change. Say why you chose it out loud; the reasoning scores
higher than the result.
Step 2 — static render from hardcoded data
function Board({ tasks }) {
return (
<div className="board">
{COLUMNS.map((col) => (
<Column
key={col}
name={col}
tasks={Object.values(tasks).filter((t) => t.column === col)}
/>
))}
</div>
);
}
function Column({ name, tasks }) {
return (
<section>
<h2>{name} <span>{tasks.length}</span></h2>
<ul>{tasks.map((t) => <Card key={t.id} task={t} />)}</ul>
</section>
);
}
No state yet. This proves the component boundaries and gives you something on screen inside ten minutes — which is what Thinking in React is about.
Step 3 — state, and where it lives
function App() {
const [tasks, setTasks] = useState(initial);
const [filter, setFilter] = useState("");
const visible = useMemo(() => {
const q = filter.trim().toLowerCase();
return Object.values(tasks).filter((t) => !q || t.title.toLowerCase().includes(q));
}, [tasks, filter]);
return (
<>
<input value={filter} onChange={(e) => setFilter(e.target.value)} placeholder="Filter" />
<Board tasks={visible} onMove={move} onDelete={remove} />
</>
);
}
Two pieces of state. visible is derived — computed
during render, never stored. The counts per column are derived from that.
Step 4 — adding, moving, deleting
function add(title, column) {
const id = crypto.randomUUID();
setTasks((t) => ({ ...t, [id]: { id, title, column } }));
}
function move(id, column) {
setTasks((t) => ({ ...t, [id]: { ...t[id], column } })); // one field
}
function remove(id) {
setTasks((t) => {
const { [id]: _gone, ...rest } = t; // destructure it out
return rest;
});
}
All three use the updater form, so two changes in one tick cannot overwrite
each other. And all three return a new object — mutating
tasks gives React the same reference and it skips the render.
Step 5 — the form
function NewTask({ onAdd }) {
const [title, setTitle] = useState("");
const id = useId();
function handleSubmit(e) {
e.preventDefault();
const clean = title.trim();
if (!clean) return;
onAdd(clean, "todo");
setTitle("");
}
return (
<form onSubmit={handleSubmit}>
<label htmlFor={id}>New task</label>
<input id={id} value={title} onChange={(e) => setTitle(e.target.value)} />
<button>Add</button>
</form>
);
}
A real <form> means Enter submits. useId means
the label works even if the component appears twice.
Step 6 — moving without drag and drop
<button
onClick={() => onMove(task.id, prevColumn)}
aria-label={"Move " + task.title + " left"}
>←</button>
<button
onClick={() => onMove(task.id, nextColumn)}
aria-label={"Move " + task.title + " right"}
>→</button>
Buttons first, drag later. They work with a keyboard, they are testable, and they take two minutes — whereas drag and drop is the thing that eats your last half hour. In an interview, say you are doing buttons first and will add drag if there is time. That reads as judgement, not as a shortcut.
Step 7 — persistence
const KEY = "board:v1";
useEffect(() => {
try { localStorage.setItem(KEY, JSON.stringify(tasks)); } catch {}
}, [tasks]);
const [tasks, setTasks] = useState(() => { // lazy — runs once
try {
const raw = localStorage.getItem(KEY);
const parsed = raw ? JSON.parse(raw) : null;
return parsed && typeof parsed === "object" ? parsed : initial;
} catch {
return initial;
}
});
The initialiser is a function, so the parse happens on the first render only.
Both accesses are wrapped — localStorage throws in private
mode and when the quota is full. And whatever is in there was written by an
older version of your own code, so check the shape. The :v1 is how
you change that shape later without breaking anyone.
Step 8 — the states you would otherwise forget
{visible.length === 0 && filter && <p>Nothing matches "{filter}".</p>}
{Object.keys(tasks).length === 0 && <p>No tasks yet. Add one above.</p>}
Two different empties — nothing at all, and nothing matching a filter — and they need different messages. Interviewers watching a machine round are counting these; handling them unprompted is one of the cheapest signals you can give.
Break it on purpose
| Try | What it teaches |
|---|---|
Use the array index as the card key | Move a card and watch state follow the wrong one |
Replace the updater form with setTasks({ ...tasks, ... }) | It works — until two updates land in one tick |
Mutate: tasks[id].column = "done"; setTasks(tasks) | Nothing re-renders. Same reference. |
Define Card inside Column | Every card remounts each render; focus and state vanish |
Store visible in state instead of deriving it | The filter and the list drift apart |
If you have time left
- Undo the last delete — keep the removed task and a timer.
- Drag and drop with the HTML drag events, or a library. Now the fractional
orderfield earns itself. - Edit in place on double-click. Watch what happens to focus when you swap the element.
- Two tabs — they disagree, and the
storageevent is the fix. - Replace storage with an API. Now you meet loading, errors, and the race where two saves land out of order.
Why this project
It contains the four things every React app has: state, a way to change it, a way to render it, and somewhere to keep it. It is also close enough to the standard machine-coding brief that building it once is direct interview preparation — and the flat data model is the decision that round is actually testing.
Opens in the editor: write it, run it, and check it against real tests.
All 46 for this level →Finished reading?
0 of 57 chapters read in this series