You've probably built a todo app before. Maybe five times.
But have you ever really understood every line you wrote?
Today we're building one more. But differently.
At every step, I'll ask you questions. Try to answer them before scrolling. This is how you actually learn—active recall beats passive reading every time.
What We're Building
A full-stack todo app with:
- React frontend with hooks
- Express.js REST API
- In-memory storage (for simplicity)
- Full CRUD operations
By the end, you'll understand:
- How
useStateanduseEffectreally work - REST API design patterns
- Frontend-backend communication
- Error handling strategies
Step 1: Project Setup
mkdir fullstack-todo && cd fullstack-todo
mkdir client server
🤔 Before we continue...
Question 1: Why are we separating client and server into different folders? What's the alternative, and when would you use it?
Think about it for 30 seconds before continuing.
The answer: Separation of concerns. We could use a monolithic approach (like Next.js), but separate folders give us deployment flexibility and clearer mental models.
Step 2: Backend Setup
cd server
npm init -y
npm pkg set type="module"
npm install express cors
Create server/index.js:
import express from 'express';
import cors from 'cors';
const app = express();
app.use(cors());
app.use(express.json());
let todos = [];
let nextId = 1;
// GET all todos
app.get('/api/todos', (req, res) => {
res.json(todos);
});
// POST new todo
app.post('/api/todos', (req, res) => {
const { title } = req.body;
const todo = { id: nextId++, title, completed: false };
todos.push(todo);
res.status(201).json(todo);
});
// PATCH toggle completion
app.patch('/api/todos/:id', (req, res) => {
const todo = todos.find(t => t.id === parseInt(req.params.id));
if (!todo) return res.status(404).json({ error: 'Todo not found' });
todo.completed = !todo.completed;
res.json(todo);
});
// DELETE todo
app.delete('/api/todos/:id', (req, res) => {
const index = todos.findIndex(t => t.id === parseInt(req.params.id));
if (index === -1) return res.status(404).json({ error: 'Todo not found' });
todos.splice(index, 1);
res.status(204).send();
});
app.listen(3001, () => console.log('Server running on port 3001'));
🤔 Stop and think...
Question 2: Why did we use 201 for POST and 204 for DELETE? What's the difference, and why does it matter?
Question 3: There's a bug in this code that would break in production. Can you spot it?
Hints:
- What happens when the server restarts?
- What happens if two users add todos simultaneously?
Step 3: Frontend Setup
cd ../client
npm create vite@latest . -- --template react
npm install
Replace src/App.jsx:
import { useState, useEffect } from 'react';
import './App.css';
const API_URL = 'http://localhost:3001/api/todos';
function App() {
const [todos, setTodos] = useState([]);
const [newTodo, setNewTodo] = useState('');
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
fetchTodos();
}, []);
async function fetchTodos() {
try {
setLoading(true);
const res = await fetch(API_URL);
if (!res.ok) throw new Error('Failed to fetch');
const data = await res.json();
setTodos(data);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}
async function addTodo(e) {
e.preventDefault();
if (!newTodo.trim()) return;
try {
const res = await fetch(API_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: newTodo }),
});
const todo = await res.json();
setTodos([...todos, todo]);
setNewTodo('');
} catch (err) {
setError('Failed to add todo');
}
}
async function toggleTodo(id) {
try {
const res = await fetch(`${API_URL}/${id}`, { method: 'PATCH' });
const updated = await res.json();
setTodos(todos.map(t => t.id === id ? updated : t));
} catch (err) {
setError('Failed to update todo');
}
}
async function deleteTodo(id) {
try {
await fetch(`${API_URL}/${id}`, { method: 'DELETE' });
setTodos(todos.filter(t => t.id !== id));
} catch (err) {
setError('Failed to delete todo');
}
}
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>;
return (
<div className="app">
<h1>My Todos</h1>
<form onSubmit={addTodo}>
<input
value={newTodo}
onChange={e => setNewTodo(e.target.value)}
placeholder="Add a new todo..."
/>
<button type="submit">Add</button>
</form>
<ul>
{todos.map(todo => (
<li key={todo.id}>
<span
onClick={() => toggleTodo(todo.id)}
style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}
>
{todo.title}
</span>
<button onClick={() => deleteTodo(todo.id)}>Delete</button>
</li>
))}
</ul>
</div>
);
}
export default App;
🤔 Understanding check...
Question 4: Why do we spread [...todos, todo] instead of todos.push(todo)? What would happen if we used push?
Question 5: The useEffect has an empty dependency array []. What does this mean? What would happen if we removed it entirely?
Step 4: Run It
Terminal 1:
cd server && node index.js
Terminal 2:
cd client && npm run dev
Visit http://localhost:5173 and test your app!
Challenges For You
If you really want to learn, try these without looking up the answers:
- Add persistence - Save todos to a JSON file
- Add validation - Prevent empty titles, limit title length
- Add optimistic updates - Update UI before server confirms
- Add filters - Show all/active/completed todos
What We Just Learned
- REST API design patterns
- React state management with hooks
- Async/await with error handling
- Frontend-backend separation
But here's the thing...
Did those questions help?
That active recall approach—stopping to think before seeing the answer—is proven to improve retention by 50% compared to passive reading.
Now imagine if you had an AI companion that could:
- Ask you these questions in real-time
- Adapt to YOUR specific confusion points
- Review YOUR actual code, not generic examples
- Remember what you've learned across projects
That's exactly what we're building at TekBreed.
Interactive tutorials and courses with AI that makes learning stick.
👉 Join the waitlist at tekbreed.com
What concept from this tutorial did you find most challenging? Drop a comment below!
Top comments (0)