Zum Inhalt springen
Kontakt

Sebastian Nawrot
Dorneystr. 45
44149 Dortmund

Webentwicklung & Technik

React Todo-App Tutorial: Von Null zur funktionierenden App

Baue eine vollständige React Todo-App mit Hooks, Express Backend und In-Memory Datastore. Schritt-für-Schritt Tutorial für Anfänger.

Sebastian Nawrot
8 Min. Lesezeit
#React#Node.js#Express#JavaScript#Tutorial#Todo App
React Todo-App Tutorial: Von Null zur funktionierenden App

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:

Möchtest du auch so einen Blog?

Ich entwickle moderne, SEO-optimierte Websites und Blogs mit Next.js, React und Tailwind CSS.