Eine Chat-Komponente baut ihre WebSocket-Verbindung jedes Mal neu auf, wenn Sie einen Buchstaben in ein Suchfeld eingeben, das mit dem Chat nichts zu tun hat. Eine andere Komponente rendert sich in einer Schleife neu, bis der Tab einfriert. Eine dritte loggt beim Mount zweimal aus ihrem useEffect, aber nur in der Entwicklung. Keiner dieser Fälle ist ein React-Bug. Die ersten beiden sind das Dependency-Array, das genau das tut, was Sie ihm gesagt haben, und der dritte ist der Strict Mode, der prüft, ob Ihr Effekt sich selbst aufräumen kann.

Wann Sie useEffect in einer React-Komponente verwenden

useEffect synchronisiert eine Komponente mit etwas, das außerhalb von React existiert: eine Server-Verbindung, eine Browser-API, einen Timer, das DOM direkt, ein Drittanbieter-Widget. React rendert Ihr JSX, committet es ins DOM und führt dann erst Ihre Effekte aus. Wenn das Rendern nicht durch eine Interaktion wie einen Klick ausgelöst wurde, lässt React den Browser den neuen Bildschirm meist erst zeichnen.

Ein Effekt ist also der falsche Ort, um einen Wert für das Rendern zu berechnen. Wenn sich fullName aus firstName und lastName ableiten lässt, schreiben Sie das während des Renderns:

1
const fullName = firstName + ' ' + lastName;

Nicht als Effekt, der nachträglich setFullName aufruft. Die Effekt-Variante rendert einmal mit dem veralteten Wert, plant ein State-Update, und rendert dann erneut. Dieser zusätzliche Durchlauf bringt nichts. Greifen Sie zu useEffect, wenn die Komponente etwas tun muss, weil sie angezeigt wurde — nicht um etwas zu berechnen, das sie direkt berechnen könnte.

Wie das Dependency-Array von useEffect funktioniert

Das zweite Argument von useEffect kommt in drei Formen, und jede sagt React etwas anderes darüber, wann die Funktion erneut laufen soll.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
useEffect(() => {
  // läuft nach jedem einzelnen Render
});

useEffect(() => {
  // läuft einmal, nachdem die Komponente zum ersten Mal gemountet wird
}, []);

useEffect(() => {
  // läuft nach dem Mount, und erneut, wann immer sich roomId ändert
}, [roomId]);

Bei einem gefüllten Array vergleicht React jeden Eintrag mit seinem Wert beim vorherigen Render, mit Object.is. Das ist dieselbe Prüfung wie ===, außer dass NaN sich selbst gleich ist und 0 und -0 unterschiedlich sind. Ist jede Dependency mit dem letzten Mal identisch, überspringt React den Effekt. Unterscheidet sich eine, läuft er erneut. Es gibt keinen Teilabgleich und keine Möglichkeit, React zu sagen: „lauf nur erneut, wenn genau diese eine Dependency sich geändert hat". Diese Logik muss im Effekt selbst stehen.

Das heißt auch: Sie können einen Wert nicht einfach aus dem Array weglassen, um den Effekt am erneuten Laufen zu hindern. Das Array sollte jeden reaktiven Wert auflisten, den der Funktionskörper liest: State, Props und alles, was daraus abgeleitet ist. Entfernen Sie einen Wert aus dem Array, lesen ihn aber weiter, läuft der Effekt mit einer veralteten Kopie. Die Regel react-hooks/exhaustive-deps aus eslint-plugin-react-hooks existiert genau dafür, diese Abweichung zu erkennen. Behandeln Sie ihre Warnungen wie Build-Fehler, statt sie mit einem Disable-Kommentar zum Schweigen zu bringen.

Was die Cleanup-Funktion von useEffect tut

Ein Effekt kann eine Funktion zurückgeben. React behandelt diesen Rückgabewert als Cleanup für das, was der Effekt gestartet hat:

1
2
3
4
5
6
7
8
useEffect(() => {
  const connection = createConnection(roomId);
  connection.connect();

  return () => {
    connection.disconnect();
  };
}, [roomId]);

React ruft das Cleanup in zwei Situationen auf: kurz bevor der Effekt erneut läuft, weil sich eine Dependency geändert hat, und wenn die Komponente unmountet. Es räumt immer den vorherigen Effekt auf, bevor der nächste startet. Verbinden, trennen, verbinden, trennen. Niemals zwei Verbindungen gleichzeitig offen.

Alles, was ein Effekt startet und einen expliziten Stopp braucht, gehört hinter eine Cleanup-Funktion: subscribe braucht unsubscribe, addEventListener braucht removeEventListener, setInterval braucht clearInterval. Dasselbe Muster löst einen subtileren Daten-Fetching-Bug, bei dem eine veraltete Antwort nach einer neueren eintrifft:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
useEffect(() => {
  let ignore = false;

  fetchBio(personId).then((result) => {
    if (!ignore) setBio(result);
  });

  return () => {
    ignore = true;
  };
}, [personId]);

Ändert sich personId, bevor sich die erste Anfrage auflöst, setzt das Cleanup ignore für diese Anfrage auf true, sodass ihr .then()-Callback nichts tut, statt einen neueren State mit älteren Daten zu überschreiben.

Warum useEffect in der Entwicklung zweimal läuft

In der Entwicklung, unter StrictMode, führt React das Setup aus, dann das Cleanup, dann wieder das Setup, direkt nach dem ersten Mount. In Produktion läuft das Setup einmal. React macht das absichtlich: Ihre Komponente wird im echten Einsatz unmountet und remountet (zum Beispiel beim Verlassen einer Route und der Rückkehr dazu), und der Strict Mode zwingt das zuerst auf Ihrer Maschine zu passieren, wo ein fehlendes Cleanup als sichtbarer Bug auffällt.

Der Fehler besteht darin, das Symptom zu flicken:

1
2
3
4
5
6
7
8
9
// Sieht aus, als würde es die "doppelte Verbindung" lösen — tut es nicht.
const connectionRef = useRef(null);

useEffect(() => {
  if (!connectionRef.current) {
    connectionRef.current = createConnection(roomId);
    connectionRef.current.connect();
  }
}, [roomId]);

Der Guard blockiert den zweiten Aufruf von connect(), aber es gibt immer noch kein Cleanup, sodass die Verbindung nach dem Unmount der Komponente offen bleibt. Wenn ein Remount einen Effekt kaputt macht, fehlt dem Effekt sein Cleanup. Schreiben Sie die Cleanup-Funktion, statt sich gegen den zweiten Lauf abzusichern.

Häufige Dependency-Fehler bei useEffect

Ein Objekt- oder Funktionsliteral, das bei jedem Render neu erzeugt wird. Zwei Objekte mit identischem Inhalt sind nicht dasselbe Objekt:

1
2
3
const options1 = { serverUrl: 'wss://chat.example.com', roomId: 'general' };
const options2 = { serverUrl: 'wss://chat.example.com', roomId: 'general' };
Object.is(options1, options2); // false

Packen Sie ein solches Objekt in ein Dependency-Array, sieht der Effekt bei jedem Render einen „neuen" Wert — egal, ob sich etwas Relevantes geändert hat:

1
2
3
4
5
6
7
8
9
function ChatRoom({ roomId }) {
  const options = { serverUrl: 'wss://chat.example.com', roomId }; // neues Objekt bei jedem Render

  useEffect(() => {
    const connection = createConnection(options);
    connection.connect();
    return () => connection.disconnect();
  }, [options]); // verbindet bei jedem Render neu, auch bei unzusammenhängenden
}

Bauen Sie das Objekt stattdessen innerhalb des Effekts auf, und hängen Sie von den primitiven Werten ab, aus denen es besteht:

1
2
3
4
5
6
7
8
function ChatRoom({ roomId }) {
  useEffect(() => {
    const options = { serverUrl: 'wss://chat.example.com', roomId };
    const connection = createConnection(options);
    connection.connect();
    return () => connection.disconnect();
  }, [roomId]); // ein String wird nach Wert verglichen
}

State nur lesen, um den nächsten State zu berechnen. Ein Interval, das count liest, um es zu erhöhen, zwingt den Effekt dazu, von count abzuhängen — was das Interval bei jedem Tick neu erzeugt:

1
2
3
4
5
6
useEffect(() => {
  const id = setInterval(() => {
    setCount(count + 1); // liest count -> count muss Dependency sein
  }, 1000);
  return () => clearInterval(id);
}, [count]); // das Interval wird jede Sekunde zerstört und neu erzeugt

Ein funktionales Update entfernt diese Lesung, und die Dependency geht mit ihr:

1
2
3
4
5
6
useEffect(() => {
  const id = setInterval(() => {
    setCount((c) => c + 1); // keine externe Lesung
  }, 1000);
  return () => clearInterval(id);
}, []); // läuft einmal

Ein Effekt, der zwei unabhängige Aufgaben erledigt. Angenommen, ein Effekt lädt die Städte eines Landes und die Bezirke einer Stadt. Stehen beide in derselben Funktion mit demselben Dependency-Array, lädt ein Städtewechsel auch die Städte neu. Trennen Sie sie: ein Effekt pro unabhängigem Synchronisationsstück, jeder mit eigenem Array.

Wann Sie kein useEffect brauchen

Zwei Muster sehen so aus, als bräuchten sie einen Effekt — brauchen aber keinen.

State zurücksetzen, wenn sich eine Prop ändert. Den lokalen State eines Formulars zu leeren, wenn sich userId ändert, ist verlockend als Effekt zu schreiben, der userId beobachtet. Das funktioniert, aber die Komponente rendert einmal mit dem alten State, bevor das Reset ankommt. Übergeben Sie stattdessen den sich ändernden Wert als key der Komponente, und React behandelt sie als neue Instanz, ganz ohne Effekt:

1
<Profile userId={userId} key={userId} />

Etwas, das wegen eines Klicks passiert, nicht wegen eines Renders. Eine „zum Warenkorb hinzugefügt"-Benachrichtigung, die product.isInCart in einem Effekt beobachtet, löst bei jedem Seiten-Reload erneut aus, wenn der Artikel schon im Warenkorb ist — weil der Effekt „gerade hinzugefügt" nicht von „schon vorhanden" unterscheiden kann. Die Benachrichtigung gehört in den Klick-Handler, der den Artikel hinzufügt, wo sie nur läuft, wenn der Nutzer klickt:

1
2
3
4
function handleBuyClick() {
  addToCart(product);
  showNotification(`${product.name} zum Warenkorb hinzugefügt`);
}

Wenn etwas passieren soll, weil der Nutzer etwas getan hat, gehört das in den Event-Handler für dieses Etwas. useEffect ist für das gedacht, was synchron bleiben muss, solange die Komponente auf dem Bildschirm ist — unabhängig davon, was der Nutzer tut. Ein Fetch mit dem Lebenszyklus der Komponente zu timen, ist genau diese Art von Problem (die Anfrageseite behandelt die Fehlerbehandlung der Fetch API), ebenso wie das Trennen eines Observers beim Unmount, etwa bei der Intersection Observer API.

Wenn das nächste Mal ein Effekt sich falsch verhält, lassen Sie das Array zunächst in Ruhe. Listen Sie auf, was der Funktionskörper liest, entscheiden Sie, ob jede Lesung wirklich reaktiv sein muss, und prüfen Sie, ob der Code ganz ohne Effekt funktioniert. Korrigieren Sie den Körper, und das richtige Array folgt daraus. Wenn Sie die Reihenfolge asynchroner Callbacks rund um ein Render verwirrt, lesen Sie wie async/await und die Event Loop funktionieren, das den Call Stack und die Microtask-Queue behandelt.

Verwandte Artikel