Create a todo list application with an input field and an add button. On clicking the button, the entered item should be added and displayed in a list. Each item should include a delete button to remove it from the list when clicked.
The 'hello world' of React interviews. State: an array of {id, text} todos plus controlled input. Add appends, delete filters by id. Watch the details: stable keys (not index), controlled input, trim/empty validation, form submit for Enter support.
The todo list is the React interview baseline — the interviewer is grading the details, not whether it works.
The implementation
function TodoApp() {
const [todos, setTodos] = useState([]);
const [text, setText] = useState("");
const addTodo = (e) => {
e.preventDefault(); // it's a form → Enter works too
const trimmed = text.trim();
if (!trimmed) return; // reject empty/whitespace
setTodos((prev) => [...prev, { id: crypto.randomUUID(), text: trimmed }]);
setText(""); // clear the input
};
const removeTodo = (id) =>
setTodos((prev) => prev.filter((t) => t.id !== id));
return (
<div>
<form onSubmit={addTodo}>
<input
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="Add a todo"
/>
<button type="submit">Add</button>
</form>
<ul>
{todos.map((todo) => (
<li key={todo.id}> {/* stable id, NOT index */}
{todo.text}
<button onClick={() => removeTodo(todo.id)}>Delete</button>
</li>
))}
</ul>
</div>
);
}What's actually being graded
- Stable keys —
key={todo.id}, notkey={index}. With index keys, deleting an item makes React reconcile wrong — input state and focus jump to the wrong row. - Controlled input —
value+onChange; React owns the input state. - Immutable updates —
[...prev, new]and.filter(), neverpush/spliceon state. - Functional state updater —
setTodos(prev => ...)avoids stale-state bugs. - Validation —
trim()and reject empty input. - A
<form>withonSubmit— so pressing Enter adds the todo, not just clicking.e.preventDefault()stops the page reload. - Clear the input after adding.
Likely follow-ups to be ready for
Toggle complete (add a done boolean, line-through styling), edit in place, filter (all/active/done), persist to localStorage, and lift state / use a reducer when it grows.
The framing
"State is an array of {id, text} plus the controlled input string. Add appends immutably with a functional updater; delete filters by id. The details that matter: a real id for the key — never the index — a <form onSubmit> so Enter works, trim-and-reject-empty validation, and clearing the input after adding. Those details are the actual test."
Follow-up questions
- •Why use todo.id instead of the array index as the key?
- •How would you add a 'mark complete' toggle?
- •How would you persist todos across page reloads?
- •When would you switch from useState to useReducer here?
Common mistakes
- •Using the array index as the key — breaks on delete/reorder.
- •Mutating state with push/splice instead of returning a new array.
- •Uncontrolled input, or forgetting to clear it after adding.
- •Using a button click instead of a form, so Enter doesn't work.
- •Not trimming/validating — empty todos get added.
Performance considerations
- •Trivial at interview scale. If the list grew large, you'd memoize rows (React.memo) and stabilize the delete callback so unchanged rows don't re-render; for thousands of items, virtualize.
Edge cases
- •Adding an empty or whitespace-only string.
- •Duplicate todo text — allowed, but ids must still be unique.
- •Rapidly adding many todos — functional updater prevents lost updates.
- •Very long todo text overflowing the layout.
Real-world examples
- •The canonical React tutorial app — every list-with-CRUD UI is this pattern.
- •Task managers, shopping lists, checklist features.