In diesem Tutorial bauen wir gemeinsam eine vollständige Todo-App mit React und einem Node.js/Express Backend. Du lernst, wie Frontend und Backend zusammenarbeiten, wie du REST APIs erstellst und wie React Hooks funktionieren.
Das Besondere: Dies ist der erste Teil einer dreiteiligen Serie. Wir starten mit einem einfachen In-Memory Datastore und erweitern in den Folgeartikeln auf MongoDB und PostgreSQL.
Warum eine Todo-App?
Todo-Apps sind das "Hello World" der Web-Entwicklung - aus gutem Grund. Sie decken alle CRUD-Operationen ab (Create, Read, Update, Delete) und sind komplex genug, um echte Konzepte zu vermitteln, aber einfach genug, um nicht zu überfordern.
Am Ende dieses Tutorials hast du:
- Ein funktionierendes Express Backend mit REST API
- Ein React Frontend mit modernen Hooks
- Verständnis für die Client-Server-Kommunikation
Projektstruktur erstellen
Wir trennen Frontend und Backend sauber in zwei Ordner. Das macht die Struktur übersichtlich und entspricht dem, wie viele Produktions-Apps aufgebaut sind.
mkdir todo-app
cd todo-app
mkdir client server
Backend initialisieren
cd server
npm init -y
npm install express cors
Frontend initialisieren
cd ../client
npm create vite@latest . -- --template react
npm install axios
Unsere Projektstruktur sieht jetzt so aus:
todo-app/
├── client/ # React Frontend
│ ├── src/
│ ├── package.json
│ └── vite.config.js
└── server/ # Express Backend
├── index.js
└── package.json
Backend mit Express aufsetzen
Wir starten mit dem Backend. Express ist ein minimalistisches Framework für Node.js, das das Erstellen von APIs sehr einfach macht.
Erstelle die Datei server/index.js:
const express = require('express');
const cors = require('cors');
const app = express();
const PORT = 3001;
// Middleware
app.use(cors());
app.use(express.json());
// In-Memory Datastore
let todos = [
{ id: 1, title: 'React lernen', completed: false },
{ id: 2, title: 'Express verstehen', completed: false },
{ id: 3, title: 'Todo-App bauen', completed: true }
];
let nextId = 4;
// Server starten
app.listen(PORT, () => {
console.log(`Server läuft auf http://localhost:${PORT}`);
});
Das cors Middleware erlaubt Anfragen von unserem React Frontend (das auf einem anderen Port läuft). Der In-Memory Datastore ist ein einfaches Array - perfekt zum Entwickeln und Testen.
REST API Endpoints
Jetzt fügen wir die CRUD-Operationen hinzu. Jede Operation bekommt einen eigenen Endpoint.
GET - Alle Todos abrufen
// GET /api/todos - Alle Todos abrufen
app.get('/api/todos', (req, res) => {
res.json(todos);
});
POST - Neues Todo erstellen
// POST /api/todos - Neues Todo erstellen
app.post('/api/todos', (req, res) => {
const { title } = req.body;
if (!title || title.trim() === '') {
return res.status(400).json({ error: 'Titel ist erforderlich' });
}
const newTodo = {
id: nextId++,
title: title.trim(),
completed: false
};
todos.push(newTodo);
res.status(201).json(newTodo);
});
PUT - Todo aktualisieren
// PUT /api/todos/:id - Todo aktualisieren
app.put('/api/todos/:id', (req, res) => {
const id = parseInt(req.params.id);
const { title, completed } = req.body;
const todoIndex = todos.findIndex(todo => todo.id === id);
if (todoIndex === -1) {
return res.status(404).json({ error: 'Todo nicht gefunden' });
}
// Nur übergebene Felder aktualisieren
if (title !== undefined) {
todos[todoIndex].title = title.trim();
}
if (completed !== undefined) {
todos[todoIndex].completed = completed;
}
res.json(todos[todoIndex]);
});
DELETE - Todo löschen
// DELETE /api/todos/:id - Todo löschen
app.delete('/api/todos/:id', (req, res) => {
const id = parseInt(req.params.id);
const todoIndex = todos.findIndex(todo => todo.id === id);
if (todoIndex === -1) {
return res.status(404).json({ error: 'Todo nicht gefunden' });
}
const deletedTodo = todos.splice(todoIndex, 1)[0];
res.json(deletedTodo);
});
Starte den Server mit:
node index.js
Du kannst die API jetzt mit curl oder einem Tool wie Postman testen:
curl http://localhost:3001/api/todos
React Frontend aufsetzen
Wechsle ins client-Verzeichnis. Vite hat bereits eine Grundstruktur erstellt. Wir räumen auf und erstellen unsere Komponenten.
Lösche den Inhalt von src/App.css und ersetze src/App.jsx:
import { useState, useEffect } from 'react';
import TodoList from './components/TodoList';
import TodoForm from './components/TodoForm';
import * as todoApi from './api/todoApi';
import './App.css';
function App() {
const [todos, setTodos] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
// Todos beim Start laden
useEffect(() => {
loadTodos();
}, []);
const loadTodos = async () => {
try {
setLoading(true);
const data = await todoApi.getAllTodos();
setTodos(data);
setError(null);
} catch (err) {
setError('Fehler beim Laden der Todos');
console.error(err);
} finally {
setLoading(false);
}
};
const addTodo = async (title) => {
try {
const newTodo = await todoApi.createTodo(title);
setTodos([...todos, newTodo]);
} catch (err) {
setError('Fehler beim Erstellen');
console.error(err);
}
};
const toggleTodo = async (id) => {
try {
const todo = todos.find(t => t.id === id);
const updated = await todoApi.updateTodo(id, {
completed: !todo.completed
});
setTodos(todos.map(t => t.id === id ? updated : t));
} catch (err) {
setError('Fehler beim Aktualisieren');
console.error(err);
}
};
const deleteTodo = async (id) => {
try {
await todoApi.deleteTodo(id);
setTodos(todos.filter(t => t.id !== id));
} catch (err) {
setError('Fehler beim Löschen');
console.error(err);
}
};
if (loading) {
return <div className="loading">Laden...</div>;
}
return (
<div className="app">
<h1>Meine Todo-Liste</h1>
{error && <div className="error">{error}</div>}
<TodoForm onAdd={addTodo} />
<TodoList
todos={todos}
onToggle={toggleTodo}
onDelete={deleteTodo}
/>
<div className="stats">
{todos.filter(t => !t.completed).length} von {todos.length} offen
</div>
</div>
);
}
export default App;
React Komponenten erstellen
Erstelle den Ordner src/components und füge die folgenden Komponenten hinzu.
TodoList.jsx
import TodoItem from './TodoItem';
function TodoList({ todos, onToggle, onDelete }) {
if (todos.length === 0) {
return (
<p className="empty-message">
Keine Todos vorhanden. Füge eines hinzu!
</p>
);
}
return (
<ul className="todo-list">
{todos.map(todo => (
<TodoItem
key={todo.id}
todo={todo}
onToggle={onToggle}
onDelete={onDelete}
/>
))}
</ul>
);
}
export default TodoList;
TodoItem.jsx
function TodoItem({ todo, onToggle, onDelete }) {
return (
<li className={`todo-item ${todo.completed ? 'completed' : ''}`}>
<input
type="checkbox"
checked={todo.completed}
onChange={() => onToggle(todo.id)}
/>
<span className="todo-title">{todo.title}</span>
<button
className="delete-btn"
onClick={() => onDelete(todo.id)}
aria-label="Todo löschen"
>
×
</button>
</li>
);
}
export default TodoItem;
TodoForm.jsx
import { useState } from 'react';
function TodoForm({ onAdd }) {
const [title, setTitle] = useState('');
const handleSubmit = (e) => {
e.preventDefault();
if (title.trim() === '') return;
onAdd(title);
setTitle('');
};
return (
<form className="todo-form" onSubmit={handleSubmit}>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Was möchtest du erledigen?"
className="todo-input"
/>
<button type="submit" className="add-btn">
Hinzufügen
</button>
</form>
);
}
export default TodoForm;
API Integration
Erstelle den Ordner src/api und die Datei todoApi.js. Diese Schicht kapselt alle HTTP-Requests und macht den Code übersichtlicher.
import axios from 'axios';
const API_URL = 'http://localhost:3001/api';
export const getAllTodos = async () => {
const response = await axios.get(`${API_URL}/todos`);
return response.data;
};
export const createTodo = async (title) => {
const response = await axios.post(`${API_URL}/todos`, { title });
return response.data;
};
export const updateTodo = async (id, updates) => {
const response = await axios.put(`${API_URL}/todos/${id}`, updates);
return response.data;
};
export const deleteTodo = async (id) => {
const response = await axios.delete(`${API_URL}/todos/${id}`);
return response.data;
};
Diese Abstraktion hat mehrere Vorteile:
- Die API-URL ist zentral definiert
- Komponenten müssen axios nicht direkt importieren
- Einfacher zu testen und zu ändern
Styling mit CSS
Ersetze den Inhalt von src/App.css mit diesem minimalistischen Styling:
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #f5f5f5;
min-height: 100vh;
padding: 2rem;
}
.app {
max-width: 500px;
margin: 0 auto;
background: white;
border-radius: 12px;
padding: 2rem;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
h1 {
text-align: center;
color: #333;
margin-bottom: 1.5rem;
}
.todo-form {
display: flex;
gap: 0.5rem;
margin-bottom: 1.5rem;
}
.todo-input {
flex: 1;
padding: 0.75rem 1rem;
border: 2px solid #e0e0e0;
border-radius: 8px;
font-size: 1rem;
transition: border-color 0.2s;
}
.todo-input:focus {
outline: none;
border-color: #4a90d9;
}
.add-btn {
padding: 0.75rem 1.5rem;
background: #4a90d9;
color: white;
border: none;
border-radius: 8px;
font-size: 1rem;
cursor: pointer;
transition: background 0.2s;
}
.add-btn:hover {
background: #357abd;
}
.todo-list {
list-style: none;
}
.todo-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 0;
border-bottom: 1px solid #eee;
}
.todo-item:last-child {
border-bottom: none;
}
.todo-item input[type="checkbox"] {
width: 20px;
height: 20px;
cursor: pointer;
}
.todo-title {
flex: 1;
color: #333;
}
.todo-item.completed .todo-title {
text-decoration: line-through;
color: #999;
}
.delete-btn {
background: none;
border: none;
color: #e74c3c;
font-size: 1.5rem;
cursor: pointer;
padding: 0 0.5rem;
opacity: 0.5;
transition: opacity 0.2s;
}
.delete-btn:hover {
opacity: 1;
}
.stats {
text-align: center;
color: #666;
margin-top: 1.5rem;
font-size: 0.9rem;
}
.error {
background: #fee;
color: #c00;
padding: 0.75rem;
border-radius: 8px;
margin-bottom: 1rem;
text-align: center;
}
.loading {
text-align: center;
padding: 2rem;
color: #666;
}
.empty-message {
text-align: center;
color: #999;
padding: 2rem 0;
}
Die App starten
Öffne zwei Terminal-Fenster:
Terminal 1 (Backend):
cd server
node index.js
Terminal 2 (Frontend):
cd client
npm run dev
Öffne http://localhost:5173 und teste deine Todo-App!
Zusammenfassung
Du hast erfolgreich eine Full-Stack Todo-App gebaut mit:
- Express Backend mit REST API (GET, POST, PUT, DELETE)
- React Frontend mit useState und useEffect Hooks
- Saubere Architektur durch Trennung von API-Schicht und Komponenten
- Fehlerbehandlung für eine bessere User Experience
Der In-Memory Datastore ist perfekt zum Entwickeln, hat aber einen Nachteil: Alle Daten gehen verloren, wenn der Server neu startet.
Im nächsten Teil dieser Serie ersetzen wir den In-Memory Datastore durch MongoDB - dann bleiben deine Todos auch nach einem Neustart erhalten.
Kompletter Code: Den vollständigen Quellcode findest du auf GitHub.
Weiterführende Links:

