Build a Todo List with Full CRUD
Interview Question: "Build a Todo app with add, edit, delete, toggle complete, filter by status, and local storage persistence."
Requirements
- Add new todos
- Edit todo text inline
- Toggle complete/incomplete
- Delete todos
- Filter: All / Active / Completed
- Persist to localStorage
- Bulk actions: Clear completed, Toggle all
Data Model
interface Todo {
id: string;
text: string;
completed: boolean;
createdAt: number;
}
type Filter = 'all' | 'active' | 'completed';React Implementation
function useTodos() {
const [todos, setTodos] = useLocalStorage<Todo[]>('todos', []);
const [filter, setFilter] = useState<Filter>('all');
const addTodo = useCallback((text: string) => {
if (!text.trim()) return;
setTodos(prev => [
...prev,
{ id: crypto.randomUUID(), text: text.trim(), completed: false, createdAt: Date.now() },
]);
}, [setTodos]);
const toggleTodo = useCallback((id: string) => {
setTodos(prev => prev.map(t => t.id === id ? { ...t, completed: !t.completed } : t));
}, [setTodos]);
const editTodo = useCallback((id: string, text: string) => {
setTodos(prev => prev.map(t => t.id === id ? { ...t, text } : t));
}, [setTodos]);
const deleteTodo = useCallback((id: string) => {
setTodos(prev => prev.filter(t => t.id !== id));
}, [setTodos]);
const clearCompleted = useCallback(() => {
setTodos(prev => prev.filter(t => !t.completed));
}, [setTodos]);
const toggleAll = useCallback(() => {
setTodos(prev => {
const allCompleted = prev.every(t => t.completed);
return prev.map(t => ({ ...t, completed: !allCompleted }));
});
}, [setTodos]);
const filteredTodos = useMemo(() => {
switch (filter) {
case 'active': return todos.filter(t => !t.completed);
case 'completed': return todos.filter(t => t.completed);
default: return todos;
}
}, [todos, filter]);
const activeCount = useMemo(() => todos.filter(t => !t.completed).length, [todos]);
return {
todos: filteredTodos,
activeCount,
filter,
setFilter,
addTodo,
toggleTodo,
editTodo,
deleteTodo,
clearCompleted,
toggleAll,
};
}TodoItem with Inline Edit
function TodoItem({
todo,
onToggle,
onEdit,
onDelete,
}: {
todo: Todo;
onToggle: () => void;
onEdit: (text: string) => void;
onDelete: () => void;
}) {
const [isEditing, setIsEditing] = useState(false);
const [editText, setEditText] = useState(todo.text);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (isEditing) inputRef.current?.focus();
}, [isEditing]);
const handleSubmit = () => {
const trimmed = editText.trim();
if (trimmed) {
onEdit(trimmed);
setIsEditing(false);
} else {
onDelete();
}
};
if (isEditing) {
return (
<li className="todo-item editing">
<input
ref={inputRef}
value={editText}
onChange={e => setEditText(e.target.value)}
onBlur={handleSubmit}
onKeyDown={e => {
if (e.key === 'Enter') handleSubmit();
if (e.key === 'Escape') {
setEditText(todo.text);
setIsEditing(false);
}
}}
/>
</li>
);
}
return (
<li className={`todo-item ${todo.completed ? 'completed' : ''}`}>
<input
type="checkbox"
checked={todo.completed}
onChange={onToggle}
aria-label={`Mark "${todo.text}" as ${todo.completed ? 'incomplete' : 'complete'}`}
/>
<span onDoubleClick={() => setIsEditing(true)}>{todo.text}</span>
<button onClick={onDelete} aria-label={`Delete "${todo.text}"`}>Ć</button>
</li>
);
}App Shell
function TodoApp() {
const {
todos, activeCount, filter, setFilter,
addTodo, toggleTodo, editTodo, deleteTodo,
clearCompleted, toggleAll,
} = useTodos();
const [input, setInput] = useState('');
return (
<div className="todo-app">
<form onSubmit={e => { e.preventDefault(); addTodo(input); setInput(''); }}>
<input
value={input}
onChange={e => setInput(e.target.value)}
placeholder="What needs to be done?"
autoFocus
/>
</form>
{todos.length > 0 && (
<>
<button onClick={toggleAll}>Toggle All</button>
<ul className="todo-list">
{todos.map(todo => (
<TodoItem
key={todo.id}
todo={todo}
onToggle={() => toggleTodo(todo.id)}
onEdit={text => editTodo(todo.id, text)}
onDelete={() => deleteTodo(todo.id)}
/>
))}
</ul>
<footer>
<span>{activeCount} item{activeCount !== 1 ? 's' : ''} left</span>
<div role="radiogroup" aria-label="Filter">
{(['all', 'active', 'completed'] as Filter[]).map(f => (
<button
key={f}
role="radio"
aria-checked={filter === f}
className={filter === f ? 'selected' : ''}
onClick={() => setFilter(f)}
>
{f.charAt(0).toUpperCase() + f.slice(1)}
</button>
))}
</div>
<button onClick={clearCompleted}>Clear Completed</button>
</footer>
</>
)}
</div>
);
}What Interviewers Look For
- Data flow ā Unidirectional state management, immutable updates
- Persistence ā localStorage sync without race conditions
- Editing UX ā Double-click to edit, Enter to save, Escape to cancel, blur to save
- Performance ā
useCallbackfor stable references,useMemofor filtered list - Edge cases ā Empty text prevention, trim whitespace, delete on empty edit