15 useEffect – Nebenwirkungen im Lebenszyklus einer Komponente

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.

15.1 Das Timing verstehen

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.

15.2 Die Anatomie eines Effects

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.

15.3 Dependencies: Die Steuerung des Re-Run

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>
  );
}

15.4 Der Fluch der instabilen Referenzen

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.

15.5 Cleanup: Aufräumen nach sich selbst

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.

15.6 Asynchrone Operationen: Die Fetch-Falle

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>;
}

15.7 Die async/await-Einschränkung

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;
  };
}, []);

15.8 Timer und Intervalle: Cleanup in Aktion

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.

15.9 Mehrere Effects: Separation of Concerns

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.

15.10 Die Endlosschleifen-Falle

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.

15.11 Development Mode: Doppelte Effect-Ausführung

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.

15.12 Wann useEffect nicht verwenden

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.