useEffect: Daten laden
Im letzten Kapitel haben wir fetchTodos geschrieben — eine einfache Funktion, die Daten von JSONPlaceholder lädt. Das Problem: Wenn wir sie direkt im Komponentenrumpf aufrufen, startet bei jedem Render eine neue Anfrage. React braucht einen Mechanismus, der Seiteneffekte kontrolliert ausführt. Das ist useEffect.
Das Grundmuster
Abschnitt betitelt „Das Grundmuster“useEffect nimmt zwei Argumente: eine Funktion mit dem Seiteneffekt und ein Dependency Array, das steuert, wann er ausgeführt wird:
import { useEffect, useState } from 'react'
type Todo = { id: number; title: string; completed: boolean; userId: number }
export default function TodoListe() { const [todos, setTodos] = useState<Todo[] | null>(null)
useEffect(() => { fetch('https://jsonplaceholder.typicode.com/todos') .then(res => res.json()) .then(data => setTodos(data)) }, [])
if (!todos) return <p>Lade...</p>
return ( <ul> {todos.map(todo => ( <li key={todo.id}>{todo.title}</li> ))} </ul> )}Das leere Array [] bedeutet: dieser Effect läuft einmal nach dem ersten Render — nicht bei jedem Re-Render. React nennt das “on mount”.
Das Dependency Array
Abschnitt betitelt „Das Dependency Array“Das Dependency Array bestimmt, wann der Effect neu läuft:
// Einmal nach dem ersten RenderuseEffect(() => { /* ... */ }, [])
// Nach jedem Render (kein Array — selten sinnvoll)useEffect(() => { /* ... */ })
// Jedes Mal, wenn sich `id` ändertuseEffect(() => { fetch(`https://jsonplaceholder.typicode.com/todos/${id}`) .then(res => res.json()) .then(data => setTodo(data))}, [id])React vergleicht die Werte im Array zwischen den Renders. Ändert sich ein Wert, wird der Effect neu ausgeführt.
Loading State
Abschnitt betitelt „Loading State“Während die Anfrage läuft, soll ein Ladezustand angezeigt werden:
export default function TodoListe() { const [todos, setTodos] = useState<Todo[] | null>(null) const [isLoading, setIsLoading] = useState(true)
useEffect(() => { setIsLoading(true) fetch('https://jsonplaceholder.typicode.com/todos') .then(res => res.json()) .then(data => { setTodos(data) setIsLoading(false) }) }, [])
if (isLoading) return <p>Lade...</p>
return ( <ul> {todos!.map(todo => ( <li key={todo.id}>{todo.title}</li> ))} </ul> )}Error State
Abschnitt betitelt „Error State“Netzwerkfehler und HTTP-Fehler müssen separat behandelt werden:
export default function TodoListe() { const [todos, setTodos] = useState<Todo[] | null>(null) const [isLoading, setIsLoading] = useState(true) const [error, setError] = useState<Error | null>(null)
useEffect(() => { setIsLoading(true) setError(null) fetch('https://jsonplaceholder.typicode.com/todos') .then(res => { if (!res.ok) throw new Error(`HTTP-Fehler: ${res.status}`) return res.json() }) .then(data => setTodos(data)) .catch(err => setError(err)) .finally(() => setIsLoading(false)) }, [])
if (isLoading) return <p>Lade...</p> if (error) return <p>Fehler: {error.message}</p>
return ( <ul> {todos!.map(todo => ( <li key={todo.id}>{todo.title}</li> ))} </ul> )}Race Conditions und AbortController
Abschnitt betitelt „Race Conditions und AbortController“Stell dir vor, der Nutzer navigiert schnell zwischen zwei Todo-Detail-Seiten. Die erste Anfrage (für Todo 1) startet, dann die zweite (für Todo 2). Wenn die erste Anfrage später zurückkommt als die zweite, wird plötzlich wieder Todo 1 angezeigt — obwohl Todo 2 angefordert wurde.
Das nennt sich Race Condition: Zwei asynchrone Operationen laufen parallel, und das Ergebnis hängt davon ab, welche zuerst fertig wird.
Die Lösung ist AbortController — damit kann eine laufende Anfrage abgebrochen werden:
useEffect(() => { const controller = new AbortController()
setIsLoading(true) setError(null)
fetch(`https://jsonplaceholder.typicode.com/todos/${id}`, { signal: controller.signal, }) .then(res => { if (!res.ok) throw new Error(`HTTP-Fehler: ${res.status}`) return res.json() }) .then(data => setTodo(data)) .catch(err => { if (err.name !== 'AbortError') setError(err) }) .finally(() => setIsLoading(false))
return () => controller.abort()}, [id])Die Cleanup-Funktion — die Funktion, die der Effect zurückgibt — wird von React aufgerufen, bevor der Effect neu läuft oder die Komponente aus dem DOM entfernt wird. Hier bricht sie die laufende Anfrage ab. Wenn der Nutzer also zu einem neuen Todo navigiert, wird die alte Anfrage sofort abgebrochen, bevor die neue startet.
TypeScript
Abschnitt betitelt „TypeScript“useState<Todo[] | null>(null) statt useState<Todo[]>([]) — der null-Initialwert unterscheidet explizit zwischen “noch nicht geladen” und “leer geladen”. Ein leeres Array könnte beides bedeuten; null bedeutet eindeutig: die Daten sind noch nicht da.
useState<Error | null>(null) ist das Standardmuster für Error State. Error ist ein eingebauter TypeScript-Typ mit .message und .name.
const [todos, setTodos] = useState<Todo[] | null>(null)const [error, setError] = useState<Error | null>(null)AbortController und AbortSignal sind in modernen TypeScript-Definitionen eingebaut — kein separater Import nötig.
Das vollständige Beispiel
Abschnitt betitelt „Das vollständige Beispiel“import { useEffect, useState } from 'react'
type Todo = { id: number; title: string; completed: boolean; userId: number }
export default function TodoDetail({ id }: { id: number }) { const [todo, setTodo] = useState<Todo | null>(null) const [isLoading, setIsLoading] = useState(true) const [error, setError] = useState<Error | null>(null)
useEffect(() => { const controller = new AbortController()
setIsLoading(true) setError(null)
fetch(`https://jsonplaceholder.typicode.com/todos/${id}`, { signal: controller.signal, }) .then(res => { if (!res.ok) throw new Error(`HTTP-Fehler: ${res.status}`) return res.json() as Promise<Todo> }) .then(data => setTodo(data)) .catch(err => { if (err.name !== 'AbortError') setError(err as Error) }) .finally(() => setIsLoading(false))
return () => controller.abort() }, [id])
if (isLoading) return <p>Lade...</p> if (error) return <p>Fehler: {error.message}</p> if (!todo) return null
return ( <div> <h2>{todo.title}</h2> <p>{todo.completed ? 'Erledigt' : 'Offen'}</p> </div> )}Die ehrliche Bilanz
Abschnitt betitelt „Die ehrliche Bilanz“Das ist der vollständige Code, um ein Todo zu laden — mit anständiger Fehlerbehandlung:
useStatefür Daten, Ladezustand und Fehler — 3 State-Variablen pro DatenabrufsetIsLoading(true)zu Beginn,finallyam Ende — manuelles ResetsetError(null)vor jeder neuen Anfrage — manuelles ResetAbortControllerfür Race Conditions — manuelles Abort-Handling- Cleanup-Funktion — manuell
- Retry bei Fehler? Nicht dabei — komplett selbst zu bauen
- Caching? Nicht dabei — jeder Render, jede Navigation lädt neu
- Background Refetch? Nicht dabei
Das funktioniert — und für einfache Fälle reicht es. Aber für eine echte Anwendung, in der viele Komponenten Daten laden, wird dieser Boilerplate zum Problem.
Im nächsten Kapitel sehen wir, wie TanStack Query all das übernimmt — und der Code auf wenige Zeilen schrumpft.