Build a To-Do App (add/edit/delete, filters)
The full todo app: array of {id, text, done}, add/edit/delete/toggle done, filter all/active/completed. Stable id keys, immutable updates, controlled inputs in a form, edit-in-place mode, derived (not stored) filtered list, empty states, localStorage persistence. useReducer once it grows.
The full todo app — CRUD plus filtering — tests whether you handle list state cleanly and catch all the details.
State
const [todos, setTodos] = useState([]); // [{ id, text, done }]
const [filter, setFilter] = useState("all"); // all | active | completed
const [editingId, setEditingId] = useState(null);The operations — immutable, keyed by id
const add = (text) =>
setTodos((t) => [...t, { id: crypto.randomUUID(), text: text.trim(), done: false }]);
const toggle = (id) =>
setTodos((t) => t.map((todo) => todo.id === id ? { ...todo, done: !todo.done } : todo));
const edit = (id, text) =>
setTodos((t) => t.map((todo) => todo.id === id ? { ...todo, text } : todo));
const remove = (id) =>
setTodos((t) => t.filter((todo) => todo.id !== id));Filtering — derived, not stored
const visible = useMemo(() => {
if (filter === "active") return todos.filter((t) => !t.done);
if (filter === "completed") return todos.filter((t) => t.done);
return todos;
}, [todos, filter]);Never keep a separate filteredTodos state — it desyncs from todos. Compute it.
Edit-in-place
editingId flags which row is editing; that row renders an input instead of text. Enter/blur saves, Escape cancels, empty text could delete-or-revert.
The full checklist of details
- Stable
idkeys — never the array index (delete/reorder corrupts rows, inputs, focus). - Immutable functional updates —
map/filter/spread,setTodos(prev => ...). - Controlled add input inside a
<form onSubmit>so Enter works; clear it after adding. - Validation —
trim(), reject empty. - Derived filtered list, memoized.
- Empty states — "no todos yet" vs "no todos match this filter."
- Persistence —
localStoragevia auseLocalStoragehook so it survives refresh. - Optional polish: item count, "clear completed," toggle-all.
Scaling
useReducer once the operations multiply (the reducer centralizes the logic cleanly); React.memo rows with stable callbacks so toggling one doesn't re-render all; virtualization for huge lists.
The framing
"Todos as an array of {id, text, done}; add/edit/delete/toggle all immutable and keyed by a stable id — never the index. The filter is derived via useMemo, not stored state that can desync. Edit-in-place is an editingId swapping the row to an input. The detail checklist that's actually being graded: stable keys, immutable functional updates, a controlled input in a real form so Enter works, trim validation, both empty states, and localStorage persistence. As it grows I'd move to useReducer and memoize the rows."
Follow-up questions
- •Why must the filtered list be derived, not stored?
- •Why is the array index a bad key here?
- •How would you implement edit-in-place cleanly?
- •When and why would you switch to useReducer?
Common mistakes
- •Array index as key.
- •Storing filteredTodos separately — desyncs.
- •Mutating the array (push/splice).
- •Button instead of a form — Enter doesn't add.
- •No empty states, no validation, no persistence.
Performance considerations
- •Interview scale is trivial. For scale: memoized rows + stable callbacks so toggling one todo doesn't re-render the list, memoized derived filter, virtualization past a few hundred items.
Edge cases
- •Editing a todo to empty text.
- •Deleting the todo currently being edited.
- •Filter showing zero results.
- •Duplicate text (allowed; ids still unique).
- •Large lists.
Real-world examples
- •The canonical React interview / tutorial app (TodoMVC).
- •Checklist and task features in real products.