Mini Project: Todo App
Time to put it all together. You'll build a fully functional Todo app using everything you've learned: components, props, state, events, lists, and forms.
What You'll Build
- Add new todos with a text input
- Mark todos as complete/incomplete
- Delete todos
- Filter by All / Active / Completed
- Show a count of remaining tasks
Before looking at the solution, try building it yourself! Start with the state shape, then the UI, then the handlers.
Step 1 — Plan the State
Think about what data your app needs before writing any JSX:
state-plan.js
// Before writing JSX, plan your state:
const state = {
todos: [
// Each todo is an object with a stable ID
{ id: 1, text: "Learn React", completed: true },
{ id: 2, text: "Build something", completed: false },
],
inputText: "", // controlled input value
filter: "all", // "all" | "active" | "completed"
};
// The operations we need:
// addTodo(text) -> create new todo, clear input
// toggleTodo(id) -> flip completed boolean
// deleteTodo(id) -> remove from array
// setFilter(filter) -> change which todos show
Step 2 — The Complete App
TodoApp.jsx
import { useState } from 'react';
// Unique ID generator
let nextId = 1;
const uid = () => nextId++;
function TodoApp() {
const [todos, setTodos] = useState([
{ id: uid(), text: 'Learn React components', completed: true },
{ id: uid(), text: 'Understand useState', completed: true },
{ id: uid(), text: 'Build a todo app', completed: false },
]);
const [input, setInput] = useState('');
const [filter, setFilter] = useState('all');
function addTodo(e) {
e.preventDefault();
const text = input.trim();
if (!text) return;
setTodos([...todos, { id: uid(), text, completed: false }]);
setInput('');
}
function toggleTodo(id) {
setTodos(todos.map(t =>
t.id === id ? { ...t, completed: !t.completed } : t
));
}
function deleteTodo(id) {
setTodos(todos.filter(t => t.id !== id));
}
const visible = todos.filter(t => {
if (filter === 'active') return !t.completed;
if (filter === 'completed') return t.completed;
return true;
});
const remaining = todos.filter(t => !t.completed).length;
return (
<div className="todo-app" style={{ maxWidth: 480, margin: '2rem auto', padding: '0 1rem' }}>
<h1>Todos</h1>
{/* Input form */}
<form onSubmit={addTodo} style={{ display: 'flex', gap: 8, marginBottom: '1rem' }}>
<input
value={input}
onChange={e => setInput(e.target.value)}
placeholder="What needs to be done?"
style={{ flex: 1, padding: '0.5rem 0.75rem', borderRadius: 6, border: '1px solid #444', background: '#1a1a2e', color: '#e6edf3' }}
/>
<button type="submit" style={{ padding: '0.5rem 1rem', background: '#3b82f6', color: '#fff', border: 'none', borderRadius: 6, cursor: 'pointer' }}>
Add
</button>
</form>
{/* Filter tabs */}
<div style={{ display: 'flex', gap: 4, marginBottom: '1rem' }}>
{['all', 'active', 'completed'].map(f => (
<button key={f} onClick={() => setFilter(f)}
style={{
padding: '4px 12px', borderRadius: 4, border: '1px solid #444',
background: filter === f ? '#3b82f6' : 'transparent',
color: filter === f ? '#fff' : '#8b949e',
cursor: 'pointer', fontSize: '0.85rem', textTransform: 'capitalize'
}}>
{f}
</button>
))}
</div>
{/* Todo list */}
{visible.length === 0 ? (
<p style={{ color: '#8b949e', textAlign: 'center', padding: '2rem' }}>
{filter === 'completed' ? 'Nothing completed yet.' : 'Nothing to do!'}
</p>
) : (
<ul style={{ listStyle: 'none', padding: 0, margin: 0 }}>
{visible.map(todo => (
<li key={todo.id} style={{
display: 'flex', alignItems: 'center', gap: 10,
padding: '0.75rem 1rem', borderBottom: '1px solid #30363d'
}}>
<input type="checkbox" checked={todo.completed}
onChange={() => toggleTodo(todo.id)}
style={{ width: 16, height: 16, cursor: 'pointer', accentColor: '#10b981' }}
/>
<span style={{
flex: 1,
textDecoration: todo.completed ? 'line-through' : 'none',
color: todo.completed ? '#484f58' : '#e6edf3'
}}>
{todo.text}
</span>
<button onClick={() => deleteTodo(todo.id)}
style={{ background: 'none', border: 'none', color: '#484f58', cursor: 'pointer', fontSize: '1rem' }}
title="Delete">
?
</button>
</li>
))}
</ul>
)}
{/* Footer */}
<div style={{ marginTop: '0.75rem', fontSize: '0.8rem', color: '#8b949e', display: 'flex', justifyContent: 'space-between' }}>
<span>{remaining} item{remaining !== 1 ? 's' : ''} left</span>
{todos.some(t => t.completed) && (
<button onClick={() => setTodos(todos.filter(t => !t.completed))}
style={{ background: 'none', border: 'none', color: '#8b949e', cursor: 'pointer', fontSize: '0.8rem' }}>
Clear completed
</button>
)}
</div>
</div>
);
}
export default TodoApp;
Step 3 — Understanding the Pattern
patterns.js
// Key React patterns used in the Todo App:
// 1. Immutable state updates
// Never mutate — always create new arrays/objects
setTodos([...todos, newTodo]); // add
setTodos(todos.filter(t => t.id !== id)); // remove
setTodos(todos.map(t => t.id === id ? {...t, completed: !t.completed} : t)); // update
// 2. Derived state (don't store what you can compute)
// BAD: store a separate filteredTodos in state
// GOOD: compute it from todos + filter during render
const visible = todos.filter(t => filter === 'all' || t.status === filter);
// 3. Controlled form with clear-on-submit
function addTodo(e) {
e.preventDefault();
if (!input.trim()) return; // guard against empty
setTodos([...todos, { id: uid(), text: input.trim(), completed: false }]);
setInput(''); // clear the input
}
// 4. Lifting state up
// All state lives in the top component.
// Children receive data as props and call handlers to request changes.
// Children never modify data directly.
What's Next?
You've mastered the fundamentals of React. From here, explore:
- React Router — multiple pages in a single-page app
- useContext — share state without prop drilling
- useReducer — manage complex state transitions
- Custom Hooks — extract and reuse stateful logic
- Next.js — React with server-side rendering and file-based routing
- TanStack Query — powerful data fetching and caching
Congratulations on completing React Fundamentals! You now have the core skills to build real React applications.