Build a simple task management interface with:
A CRUD task UI: add/edit/delete/toggle tasks, filter by status, maybe priority/due date. State is an array of {id, title, done, ...}; immutable updates; stable id keys. Watch for: controlled inputs, derived (not stored) filtered list, edit-in-place mode, and persistence.
A task management UI is the CRUD-list pattern with a few extra dimensions (status, filtering, edit-in-place). The interviewer grades correctness fundamentals.
State shape
const [tasks, setTasks] = useState([]); // [{ id, title, done, priority, dueDate }]
const [filter, setFilter] = useState("all"); // all | active | done
const [editingId, setEditingId] = useState(null);The operations — all immutable, keyed by id
const addTask = (title) =>
setTasks((t) => [...t, { id: crypto.randomUUID(), title, done: false }]);
const toggleTask = (id) =>
setTasks((t) => t.map((task) => task.id === id ? { ...task, done: !task.done } : task));
const editTask = (id, title) =>
setTasks((t) => t.map((task) => task.id === id ? { ...task, title } : task));
const deleteTask = (id) =>
setTasks((t) => t.filter((task) => task.id !== id));Filtering — derived, NOT stored
const visibleTasks = useMemo(() => {
if (filter === "active") return tasks.filter((t) => !t.done);
if (filter === "done") return tasks.filter((t) => t.done);
return tasks;
}, [tasks, filter]);The filtered list is derived state — compute it with useMemo, never keep a separate filteredTasks useState that can desync from tasks.
Edit-in-place
Track editingId; the row renders an input instead of text when task.id === editingId; save commits the edit and clears editingId, Escape cancels.
What's being graded
- Stable
idkeys, never the array index — or toggling/deleting corrupts rows. - Immutable updates —
map/filter/spread, functional updaters; neverpush/mutate. - Controlled inputs for add and edit; a real
<form onSubmit>so Enter works. - Derived filtered list, not stored.
- Validation — trim, reject empty titles.
- Empty states — "no tasks," "no tasks match this filter."
- Persistence —
localStorage(auseLocalStoragehook) so tasks survive refresh.
Scaling up (follow-ups)
useReducer once operations multiply; memoized task rows + stable callbacks for long lists; virtualization for very long lists; sorting by priority/due date; optimistic updates if it's server-backed.
The framing
"It's the CRUD-list pattern: tasks as an array of {id, title, done, ...}, with add/toggle/edit/delete done immutably and keyed by a stable id — never the index. The filter is derived state via useMemo, not a separate useState that can desync. Edit-in-place is an editingId toggling the row between text and input. The fundamentals being graded are stable keys, immutable functional updates, controlled inputs in a real form, and the empty states. I'd persist to localStorage, and reach for useReducer plus memoized rows as it grows."
Follow-up questions
- •Why is the filtered list derived state, not stored state?
- •Why use a stable id instead of the array index as the key?
- •How would you implement edit-in-place?
- •When would you switch to useReducer?
Common mistakes
- •Array index as key — corrupts rows on toggle/delete.
- •Storing filteredTasks in separate state that desyncs.
- •Mutating the tasks array with push/splice.
- •No empty states; no validation on the add input.
- •Not persisting — refresh loses everything.
Performance considerations
- •Interview scale is trivial. For long lists: memoize rows (React.memo) with stable callbacks so toggling one task doesn't re-render all; virtualize beyond a few hundred; memoize the derived filtered list.
Edge cases
- •Empty title submitted.
- •Deleting the task currently being edited.
- •Filter showing zero results.
- •Rapidly toggling many tasks (functional updater).
- •Very long task list.
Real-world examples
- •Todoist/Trello-style task UIs; checklist features in larger apps.
- •The canonical CRUD interview exercise.