React-Komponenten sind im Idealfall reine Funktionen: Sie nehmen Props und State entgegen, berechnen daraus JSX und geben es zurück. Dieser deterministische Prozess ist vorhersagbar, testbar und effizient. Die Realität von Webanwendungen erfordert jedoch mehr. Komponenten müssen mit APIs kommunizieren, auf Fensterereignisse reagieren, Timer verwalten oder den Browser-Storage synchronisieren. All diese Operationen finden außerhalb der Rendering-Pipeline statt – sie sind Side Effects, Nebenwirkungen, die mit der Außenwelt interagieren.
Der useEffect-Hook ist React’s Antwort auf diese
Anforderung. Er bietet einen strukturierten Mechanismus, um Side Effects
zu einem kontrollierten Zeitpunkt auszuführen: nach dem Rendern, wenn
das DOM bereits aktualisiert wurde und die Benutzeroberfläche den
aktuellen Zustand widerspiegelt.
Der Schlüssel zu useEffect liegt in seinem Timing. Im
Gegensatz zu regulärem Code im Komponenten-Body, der synchron während
des Renderings läuft, wird der Code in useEffect asynchron
nach Abschluss des Renderings ausgeführt. React garantiert, dass der
Browser bereits die Chance hatte, den Bildschirm zu aktualisieren, bevor
der Effect läuft.
Dieses Verhalten hat weitreichende Konsequenzen. Side Effects blockieren nicht das Rendering – die Benutzeroberfläche bleibt responsiv, selbst wenn der Effect Zeit benötigt. Gleichzeitig bedeutet es, dass Änderungen am DOM, die innerhalb eines Effects vorgenommen werden, möglicherweise ein kurzes Flackern verursachen, weil sie nach dem initialen Paint stattfinden.
function DocumentTitle() {
const [count, setCount] = useState(0);
useEffect(() => {
// Läuft NACH dem Rendering
document.title = `Geklickt: ${count} mal`;
});
return <button onClick={() => setCount(count + 1)}>Klick mich</button>;
}
Dieser Effect aktualisiert den Dokumenttitel bei jedem Rendering. Der Titel ändert sich nach dem Paint, nicht währenddessen. Für die meisten Anwendungsfälle ist dieses asynchrone Verhalten genau richtig – aber es gibt Ausnahmen, die wir später betrachten werden.
Ein useEffect-Aufruf besteht aus zwei Teilen: der
Effect-Funktion und dem optionalen Dependency Array. Die Effect-Funktion
enthält den Code, der ausgeführt werden soll. Das Dependency Array
bestimmt, wann dieser Code läuft.
useEffect(() => {
// Effect-Code hier
}, [/* Dependencies */]);
Die drei grundlegenden Varianten unterscheiden sich durch das Dependency Array:
| Variante | Verhalten | Anwendungsfall |
|---|---|---|
| Kein Array | Effect läuft nach jedem Render | Selten sinnvoll; meist Fehler |
Leeres Array [] |
Effect läuft einmal beim Mount | Initialisierung, Setup |
Mit Dependencies [a, b] |
Effect läuft, wenn sich a oder b
ändern |
Synchronisation mit State/Props |
Die Variante ohne Dependency Array ist technisch möglich, aber in der Praxis selten gewünscht. Sie führt den Effect nach jedem Rendering aus, was zu Performance-Problemen und oft zu logischen Fehlern führt. Die meisten Effects sollten entweder einmalig beim Mount oder abhängig von spezifischen Werten laufen.
Das Dependency Array ist das Herzstück der Effect-Steuerung. React merkt sich die Werte aus dem vorherigen Rendering und vergleicht sie mit den aktuellen Werten. Nur wenn mindestens ein Wert sich geändert hat, wird der Effect erneut ausgeführt.
Der Vergleich erfolgt mit Object.is, was für primitive
Werte wie Zahlen, Strings und Booleans intuitiv funktioniert. Bei
Objekten und Arrays wird es komplexer: Zwei Objekte mit identischem
Inhalt gelten als verschieden, wenn sie unterschiedliche Referenzen
haben.
function UserProfile({ userId }: { userId: number }) {
const [user, setUser] = useState<User | null>(null);
useEffect(() => {
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(data => setUser(data));
}, [userId]); // Effect läuft neu, wenn sich userId ändert
return user ? <div>{user.name}</div> : <div>Lädt...</div>;
}
Hier deklarieren wir userId als Dependency. Ändert sich
die Prop, wird automatisch ein neuer API-Aufruf ausgelöst. Der alte
Aufruf läuft weiter – ein Problem, das wir mit Cleanup-Funktionen lösen
werden.
Die Regel für Dependencies ist einfach: Jeder Wert aus dem Komponenten-Scope, der im Effect verwendet wird und sich zwischen Renderings ändern kann, muss im Array stehen. Props, State-Variablen, abgeleitete Werte – alles, was der Effect “sieht”, gehört hinein.
function SearchResults({ query }: { query: string }) {
const [results, setResults] = useState<string[]>([]);
const [page, setPage] = useState(1);
useEffect(() => {
// Verwendet sowohl query als auch page
fetch(`/api/search?q=${query}&page=${page}`)
.then(res => res.json())
.then(data => setResults(data));
}, [query, page]); // Beide müssen als Dependencies gelistet sein
return (
<div>
{results.map(r => <div key={r}>{r}</div>)}
<button onClick={() => setPage(page + 1)}>Mehr laden</button>
</div>
);
}
Die Referenz-Sensitivität von Object.is führt zu einem
häufigen Problem: Objekte oder Arrays, die inline im Komponenten-Body
definiert werden, erhalten bei jedem Rendering eine neue Referenz –
selbst wenn ihr Inhalt identisch ist.
function ProblematicComponent() {
const [data, setData] = useState(null);
const config = { timeout: 5000 }; // Neue Referenz bei jedem Render!
useEffect(() => {
fetchWithConfig(config).then(setData);
}, [config]); // Führt zu neuem Effect bei jedem Render
}
In diesem Beispiel wird config bei jedem Rendering neu
erstellt. Auch wenn der Inhalt identisch ist
({ timeout: 5000 }), unterscheiden sich die Referenzen. Der
Effect sieht eine “Änderung” und läuft erneut – möglicherweise unendlich
oft, wenn der Effect seinerseits ein Re-Rendering auslöst.
Die Lösungen für dieses Problem sind vielfältig:
Option 1: Werte außerhalb der Komponente definieren
const CONFIG = { timeout: 5000 }; // Stabile Referenz
function BetterComponent() {
useEffect(() => {
fetchWithConfig(CONFIG).then(setData);
}, []); // CONFIG ist stabil, keine Dependency nötig
}
Option 2: useMemo für berechnete Objekte
function ComponentWithMemo() {
const [timeout, setTimeout] = useState(5000);
const config = useMemo(
() => ({ timeout }),
[timeout]
);
useEffect(() => {
fetchWithConfig(config).then(setData);
}, [config]); // Ändert sich nur, wenn timeout sich ändert
}
Option 3: Primitive Werte als Dependencies
function ComponentWithPrimitives() {
const timeout = 5000;
useEffect(() => {
fetchWithConfig({ timeout }).then(setData);
}, [timeout]); // Primitive als Dependency
}
Die dritte Option ist oft die eleganteste: Statt das Objekt als Dependency zu verwenden, nutzen wir die primitiven Werte, von denen es abhängt.
Viele Effects erzeugen Ressourcen, die explizit freigegeben werden müssen. Event Listener, Timer, WebSocket-Verbindungen, Subscriptions – all diese Dinge bleiben bestehen, wenn sie nicht aktiv entfernt werden. Ohne Cleanup häufen sich diese Ressourcen an und führen zu Memory Leaks oder unerwartetem Verhalten.
Die Cleanup-Funktion ist eine Funktion, die vom Effect zurückgegeben wird. React ruft sie auf, bevor der Effect das nächste Mal läuft oder wenn die Komponente unmounted.
function WindowSize() {
const [size, setSize] = useState(window.innerWidth);
useEffect(() => {
function handleResize() {
setSize(window.innerWidth);
}
// Event Listener hinzufügen
window.addEventListener('resize', handleResize);
// Cleanup: Event Listener entfernen
return () => {
window.removeEventListener('resize', handleResize);
};
}, []); // Nur beim Mount/Unmount
return <div>Fensterbreite: {size}px</div>;
}
Der Ablauf bei Komponenten-Lifecycle-Events sieht so aus:
Bei Dependencies ist die Cleanup-Logik besonders wichtig. Wenn sich eine Dependency ändert, führt React zuerst die Cleanup-Funktion des alten Effects aus, bevor der neue Effect mit den aktualisierten Werten läuft. Dies verhindert, dass “verwaiste” Ressourcen zurückbleiben.
function ChatRoom({ roomId }: { roomId: string }) {
useEffect(() => {
const connection = connectToRoom(roomId);
return () => {
connection.disconnect(); // Alte Verbindung trennen
};
}, [roomId]); // Bei Raumwechsel: erst disconnecten, dann neu connecten
return <div>Verbunden mit Raum {roomId}</div>;
}
Wechselt der User von Raum A zu Raum B, läuft folgende Sequenz ab: 1. Cleanup von Raum A wird ausgeführt (Verbindung trennen) 2. Effect für Raum B wird ausgeführt (neue Verbindung aufbauen)
Ohne Cleanup würden sich Verbindungen anhäufen, eine für jeden besuchten Raum.
API-Aufrufe sind einer der häufigsten Use Cases für
useEffect. Die Implementierung ist jedoch tückischer als
sie zunächst erscheint. Ein naiver Ansatz führt schnell zu Race
Conditions und anderen subtilen Bugs.
function UserData({ userId }: { userId: number }) {
const [user, setUser] = useState<User | null>(null);
useEffect(() => {
// Problematisch: Was passiert, wenn userId sich schnell ändert?
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(data => setUser(data));
}, [userId]);
return user ? <div>{user.name}</div> : <div>Lädt...</div>;
}
Das Problem: Wenn userId sich von 1 zu 2 zu 3 ändert,
werden drei Requests gestartet. Sie kommen möglicherweise in beliebiger
Reihenfolge an. Request für User 1 könnte als letzter ankommen und die
Daten von User 3 überschreiben. Das Ergebnis: Die UI zeigt User 1,
obwohl userId aktuell 3 ist.
Die Lösung liegt in einem Ignore-Flag und Cleanup:
function UserData({ userId }: { userId: number }) {
const [user, setUser] = useState<User | null>(null);
useEffect(() => {
let ignore = false; // Flag für veraltete Requests
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(data => {
if (!ignore) { // Nur setzen, wenn noch aktuell
setUser(data);
}
});
return () => {
ignore = true; // Cleanup: diesen Request als veraltet markieren
};
}, [userId]);
return user ? <div>{user.name}</div> : <div>Lädt...</div>;
}
Bei modernen APIs mit AbortController können wir noch
einen Schritt weiter gehen und den Request tatsächlich abbrechen:
function UserData({ userId }: { userId: number }) {
const [user, setUser] = useState<User | null>(null);
useEffect(() => {
const controller = new AbortController();
fetch(`/api/users/${userId}`, { signal: controller.signal })
.then(res => res.json())
.then(data => setUser(data))
.catch(error => {
if (error.name !== 'AbortError') {
console.error('Fetch failed:', error);
}
});
return () => {
controller.abort(); // Request aktiv abbrechen
};
}, [userId]);
return user ? <div>{user.name}</div> : <div>Lädt...</div>;
}
Die Effect-Funktion selbst kann nicht async sein.
Folgender Code führt zu einem Fehler:
// Falsch: Effect-Funktion darf nicht async sein
useEffect(async () => {
const data = await fetch('/api/data');
setData(data);
}, []);
Der Grund liegt darin, dass eine async-Funktion immer
ein Promise zurückgibt – aber useEffect erwartet entweder
nichts oder eine Cleanup-Funktion. Die Lösung ist eine async-Funktion
innerhalb des Effects zu definieren und sofort aufzurufen:
useEffect(() => {
async function fetchData() {
const response = await fetch('/api/data');
const data = await response.json();
setData(data);
}
fetchData();
}, []);
Mit Cleanup und Fehlerbehandlung:
useEffect(() => {
let ignore = false;
async function fetchData() {
try {
const response = await fetch('/api/data');
const data = await response.json();
if (!ignore) {
setData(data);
}
} catch (error) {
if (!ignore) {
setError(error);
}
}
}
fetchData();
return () => {
ignore = true;
};
}, []);
Timer sind ein klassisches Beispiel für Effects mit Cleanup. Ein
setTimeout oder setInterval läuft weiter, bis
er explizit gestoppt wird – selbst wenn die Komponente längst unmounted
ist.
function Countdown({ seconds }: { seconds: number }) {
const [remaining, setRemaining] = useState(seconds);
useEffect(() => {
if (remaining <= 0) return;
const timer = setTimeout(() => {
setRemaining(remaining - 1);
}, 1000);
return () => clearTimeout(timer);
}, [remaining]);
return <div>{remaining} Sekunden verbleibend</div>;
}
Dieser Effect nutzt remaining als Dependency. Bei jeder
Änderung wird der alte Timer cleared und ein neuer gestartet. Die
Cleanup-Funktion stellt sicher, dass kein Timer zurückbleibt, wenn die
Komponente unmounted.
Bei setInterval ist die Cleanup-Funktion noch
kritischer:
function Clock() {
const [time, setTime] = useState(new Date());
useEffect(() => {
const interval = setInterval(() => {
setTime(new Date());
}, 1000);
return () => clearInterval(interval); // Essentiell!
}, []); // Leeres Array: Interval einmal beim Mount erstellen
return <div>{time.toLocaleTimeString()}</div>;
}
Ohne die Cleanup-Funktion würde jedes Re-Rendering der Eltern-Komponente ein neues Interval erstellen. Nach kurzer Zeit liefen Dutzende Intervalle parallel – jedes aktualisiert die Zeit, aber nur das erste würde jemals gestoppt.
Eine häufige Versuchung ist es, alle Side Effects einer Komponente in
einen einzigen großen useEffect-Aufruf zu packen. Das führt
jedoch zu schwer wartbarem Code und problematischen Dependencies.
// Unübersichtlich: Alles in einem Effect
function Dashboard({ userId }: { userId: number }) {
useEffect(() => {
// User-Daten laden
fetch(`/api/users/${userId}`).then(/*...*/);
// Websocket-Verbindung
const ws = new WebSocket(`/ws/user/${userId}`);
// Event Listener
window.addEventListener('resize', handleResize);
return () => {
ws.close();
window.removeEventListener('resize', handleResize);
};
}, [userId]);
}
Besser ist es, jeden logischen Concern in einen eigenen Effect zu separieren:
function Dashboard({ userId }: { userId: number }) {
// User-Daten laden
useEffect(() => {
let ignore = false;
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(data => {
if (!ignore) setUser(data);
});
return () => { ignore = true; };
}, [userId]);
// Websocket-Verbindung
useEffect(() => {
const ws = new WebSocket(`/ws/user/${userId}`);
return () => ws.close();
}, [userId]);
// Event Listener (unabhängig von userId)
useEffect(() => {
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
}
Diese Struktur hat mehrere Vorteile. Jeder Effect hat klare
Dependencies – der Resize-Listener benötigt userId gar
nicht als Dependency. Der Code ist lesbarer und jeder Effect kann
unabhängig verstanden werden. Änderungen an einem Concern beeinflussen
nicht die anderen.
Ein klassischer Fehler beim Arbeiten mit useEffect sind
unbeabsichtigte Endlosschleifen. Sie entstehen, wenn ein Effect State
ändert, was ein Re-Rendering auslöst, was den Effect erneut ausführt,
was wieder State ändert.
function InfiniteLoop() {
const [count, setCount] = useState(0);
useEffect(() => {
setCount(count + 1); // Ändert State
}); // Kein Dependency Array: läuft nach jedem Render!
return <div>{count}</div>;
}
Dieser Code friert den Browser ein. Der Effect läuft, setzt
count, löst ein Re-Rendering aus, das den Effect erneut
startet – ad infinitum.
Die Lösung liegt entweder in einem Dependency Array, das verhindert, dass der Effect bei jedem Render läuft, oder in einer Bedingung, die den State-Update verhindert, wenn er nicht nötig ist.
Subtiler sind Endlosschleifen durch instabile Dependencies:
function SubtleInfiniteLoop() {
const [data, setData] = useState([]);
const options = { limit: 10 }; // Neue Referenz bei jedem Render!
useEffect(() => {
fetch(`/api/data`, { body: JSON.stringify(options) })
.then(res => res.json())
.then(setData);
}, [options]); // options ändert sich ständig -> Endlosschleife
}
Jedes Re-Rendering erstellt ein neues options-Objekt.
Der Effect sieht eine Änderung, lädt Daten, setzt State, triggert
Re-Rendering, erstellt neues options-Objekt – die Schleife
beginnt von vorn.
In React’s Strict Mode (standardmäßig aktiv in Create React App und anderen Tools) führt React Effects in der Entwicklungsumgebung doppelt aus: einmal normal, dann Cleanup, dann erneut. Dieses Verhalten kann verwirrend sein, dient aber einem wichtigen Zweck.
Die doppelte Ausführung deckt Probleme auf, die in Production zu Bugs führen würden. Effects, die nicht ordnungsgemäß aufräumen, funktionieren beim ersten Durchlauf noch, zeigen aber Probleme beim zweiten. Es ist ein Test: Kann dein Effect mit Unmount/Remount umgehen?
useEffect(() => {
console.log('Effect läuft');
return () => {
console.log('Cleanup läuft');
};
}, []);
// Development Mode Output:
// Effect läuft
// Cleanup läuft
// Effect läuft
// Production Output:
// Effect läuft
Die doppelte Ausführung ist kein Bug, sondern ein Feature. Sie simuliert Szenarien, die in Production auftreten können – etwa wenn eine Komponente zwischen verschiedenen Tabs hin- und herwechselt oder wenn React-Concurrent-Features Renderings abbrechen und neu starten.
Nicht jeder Code, der nach dem Rendering laufen soll, gehört in
useEffect. Es gibt klare Fälle, in denen andere Patterns
besser geeignet sind.
Berechnungen basierend auf Props/State gehören nicht
in Effects. Sie sollten direkt im Komponenten-Body stattfinden oder, bei
teuren Berechnungen, mit useMemo optimiert werden.
// Falsch: Berechnung in useEffect
function Total({ items }: { items: number[] }) {
const [total, setTotal] = useState(0);
useEffect(() => {
setTotal(items.reduce((sum, item) => sum + item, 0));
}, [items]);
return <div>Total: {total}</div>;
}
// Richtig: Direkte Berechnung
function Total({ items }: { items: number[] }) {
const total = items.reduce((sum, item) => sum + item, 0);
return <div>Total: {total}</div>;
}
Event-Handler sollten nicht durch Effects ersetzt
werden. Ein Button-Click-Handler gehört als onClick-Prop an
den Button, nicht in einen Effect, der auf State-Änderungen
reagiert.
Initialisierung von State kann oft besser mit lazy
initialization in useState gelöst werden, statt mit einem
Effect, der beim Mount läuft.
// Umständlich: Effect für Initialisierung
function Component() {
const [value, setValue] = useState('');
useEffect(() => {
setValue(localStorage.getItem('savedValue') || '');
}, []);
}
// Besser: Lazy initialization
function Component() {
const [value, setValue] = useState(() =>
localStorage.getItem('savedValue') || ''
);
}
Die Faustregel: useEffect ist für Synchronisation mit
externen Systemen gedacht. Wenn der Code rein innerhalb von React
arbeitet und nur von Props/State abhängt, gehört er nicht in einen
Effect.