Startseite / Artikel

Warum React useEffect-Callbacks niemals asynchrone Funktionen sein sollten

Eine asynchrone Funktion, die an useEffect übergeben wird, bricht Reacts Cleanup-Vertrag, da sie einen Promise zurückgibt. Vier korrekte Muster für asynchrone Aufgaben innerhalb von Effects.

Verlag: REACTAPP.TOP14 Min. Lesezeit3106 Wörter

Quellen: React Interview Question Asked in Paytm

Diese Frage tauchte während eines Frontend-Interviews bei Paytm auf und verdient mehr Anerkennung, als ihre Einfachheit vermuten lässt. Auf den ersten Blick wirkt sie wie ein kleiner Syntax-Problemfall. Tatsächlich prüft sie, ob man das Lebenszykluskonzept von Effekten versteht, wie JavaScript-Promises tatsächlich funktionieren, was React intern mit dem zurückgegebenen Wert eines Effekts macht und wie man innerhalb einer Komponente Daten auf die richtige Weise abruft.

Die meisten Kandidaten geben eine technisch korrekte, aber nur oberflächliche Antwort:

„useEffect erwartet, dass man eine Cleanup-Funktion zurückgibt, während eine asynchrone Funktion stattdessen einen Promise zurückgibt.“

Das ist ein vernünftiger Einstieg, lässt aber die interessanten Fragen unbeantwortet. Was macht React mit dieser Promise, sobald es sie erhält? Wird sie einfach weggeworfen? Was bricht dadurch tatsächlich? Und, was noch nützlicher ist: Wie sollte man asynchrone Logik innerhalb eines Effects strukturieren, damit nichts kaputtgeht?

Lassen Sie uns das Gesamtbild Schritt für Schritt aufbauen und jede Aussage mit der React-Dokumentation sowie dem Quellcode von React abgleichen.

Teil 1: Der Vertrag, den useEffect erwartet

Die in useEffect übergebene Funktion wird offiziell als Setup-Funktion bezeichnet, und Reacts Dokumentation ist streng bezüglich dessen, was sie zurückgeben darf. Es gibt nur zwei zulässige Ergebnisse:

  • Das Zurückgeben von nichts, was bedeutet, dass der Wert undefined ist, oder
  • Das Zurückgeben einer Cleanup-Funktion, die React zu einem späteren Zeitpunkt aufruft.
useEffect(() => {
  // setup: runs after the component is committed to the screen
  const id = setInterval(tick, 1000);
  // cleanup: React calls this before the next setup run,
  // and one final time when the component unmounts
  return () => clearInterval(id);
}, []);

Diese Cleanup-Funktion ist nicht nur empfehlenswert – sie ist das Mechanismus, auf den React zurückgreift, um alles zu zerstören, was in der Setup-Phase erstellt wurde, sei es ein Timer, eine Abonnementverbindung, ein Event-Listener oder eine offene WebSocket-Verbindung. Laut der React-Dokumentation ruft React Ihre Cleanup-Funktion unmittelbar vor dem erneuten Ausführen des Effects auf und ein weiteres Mal, wenn das Komponente endgültig abgemontiert wird.

Der entscheidende Punkt, der diese Frage so wichtig macht: React benötigt die Cleanup-Funktion sofort, sobald Ihre Setup-Funktion ausgeführt ist. Manchmal muss die Aufräumarbeit fast sofort erfolgen – möglicherweise noch bevor das erste await in einer asynchronen Funktion abgeschlossen ist. Eine Cleanup-Funktion, die erst „später“ zur Verfügung steht, kann von React nicht genutzt werden.

Teil 2: Warum async immer Promise bedeutet

Dieser Abschnitt bezieht sich auf reines JavaScript-Verhalten, und es gibt dabei keine Ausnahmen. Laut MDN geben Funktionen, die mit async deklariert sind, immer ein Promise zurück – Punkt. Selbst wenn man etwas zurückgibt, das noch kein Promise ist, umhüllt JavaScript es stillschweigend für einen in ein Promise. Es gibt keine Syntax, mit der man diesem Verhalten ausweichen kann.

async function getCleanup() {
  return () => console.log('cleaning up');
}
const result = getCleanup();console.log(result);
// Promise { <fulfilled>: f }   <- the function is trapped insideconsole.log(typeof result);
// "object"   <- not "function"

Betrachten Sie genau, was dort passiert ist. Der Code gibt ausdrücklich eine Pfeilfunktion zurück, und dennoch erhält der Aufrufer keine Funktion. Stattdessen bekommt er ein Promise, das letztendlich zu dieser Funktion auflöst. Das gilt auch für eine async-Funktion ohne jegliche Return-Anweisung – sie gibt weiterhin ein Promise zurück, das zu undefined auflöst.

Vergleichen wir die beiden Aspekte miteinander:

  1. React erwartet entweder undefined oder eine tatsächliche Funktion, die synchron übergeben wird.
  • Eine asynchrone Funktion liefert immer ein Promise, egal was passiert.
  • Daher wird der Vertrag bereits vor dem Ausführen der Logik für die Auswirkungen verletzt.

    // What you think you wrote:
    useEffect(async () => {
      const data = await fetchUser(id);
      setUser(data);
      return () => controller.abort(); // "my cleanup"
    }, [id]);
    
    // What React receives from your setup function:
    // Promise { <pending> }
    // Your cleanup is locked inside a box React never opens.
    

    Teil 3: Was React tatsächlich mit Ihrem Rückgabewert macht

    Hier gehen viele Erklärungen falsch. Oft wird gesagt, dass „React das zurückgegebene Promise einfach ignoriert“. Der Quellcode erzählt jedoch eine andere Geschichte. React führt tatsächlich drei getrennte Aktionen mit diesem zurückgegebenen Wert durch, und zwei davon verursachen echte Probleme.

    3.1 Es speichert das Promise als Aufräumfunktion

    Tiefer im Reconciler, genauer gesagt in commitHookEffectListMount – der Funktion, die dafür verantwortlich ist, Ihre Effekte nach einem Commit auszuführen – ruft React Ihre Setup-Funktion auf und speichert den Rückgabewert direkt, ohne weitere Fragen. In einer Produktionsversion gibt es keine Laufzeitprüfung, um eine nicht übereinstimmende Typenkonfiguration zu erkennen:

    // react-reconciler/src/ReactFiberCommitEffects.js (simplified)
    const create = effect.create;   // your setup function
    const destroy = create();       // whatever it returns...
    inst.destroy = destroy;         // ...is stored as the cleanup
    

    Falls die von Ihnen übergebene Setup-Funktion asynchron war, enthält inst.destroy nun ein ausstehendes Promise genau an der Stelle, an der React eine aufrufbare Aufräumfunktion erwartet.

    3.2 Die Entwicklungsversion gibt Ihnen eine sehr spezifische Warnung

    Sobald der Wert gespeichert wurde, führen die Entwicklungsbuilds einen zusätzlichen Validierungsschritt durch. React prüft, ob typeof destroy !== 'function' gilt, und geht dabei über eine einfache Typprüfung hinaus – es sucht gezielt nach einer .then-Methode im zurückgegebenen Wert. Das ist ein deutliches Zeichen dafür, dass man ihm eine Promise statt einer Funktion übergeben hat, und React weist dies direkt aus:

    useEffect must not return anything besides a function, which is used for
    clean-up.
    
    It looks like you wrote useEffect(async () => ...) or returned a Promise.
    Instead, write the async function inside your effect and call it immediately:useEffect(() => {
      async function fetchData() {
        // You can await here
        const response = await MyAPI.getData(someId);
        // ...
      }
      fetchData();
    }, [someId]); // Or [] if effect doesn't need props or stateLearn more about data fetching with Hooks: https://react.dev/link/hooks-data-fetching
    

    Beachten Sie, dass die Warnung nicht nur kritisiert – sie liefert Ihnen bereits im Nachrichtentext die Lösung, indem sie das Muster zur Deklaration einer asynchronen Funktion innerhalb des Effects sowie deren sofortige Aufrufung zeigt. Wenn der eigene Fehlertext eines Frameworks bereits die Lösung enthält, ist das in der Regel ein starkes Zeichen dafür, dass die Interviewer erwarten, dass Sie dies bereits gesehen haben.

    3.3 Wenn die Aufräumzeit kommt, versucht React, die Promise aufzurufen

    Hier wird es wirklich gefährlich. Wenn sich die Abhängigkeiten des Effekts ändern oder das Komponente abgemontiert wird, überprüft Reacts Abmontierungslogik nur, ob etwas als Aufräumfunktion gespeichert wurde – sie prüft niemals, ob der gespeicherte Wert aufrufbar ist:

    // commitHookEffectListUnmount (simplified)
    const destroy = inst.destroy;
    if (destroy !== undefined) {
      safelyCallDestroy(effect, ...); // executes destroy()
    }
    
    // destroy is a Promise here, and a Promise is not callable:
    // TypeError: destroy is not a function
    

    Wenn man versucht, eine Promise wie eine Funktion aufzurufen, tritt ein TypeError auf. Genau das verursachte den bekannten Absturz „Uncaught TypeError: destroy is not a function“ in React 17. In aktuellen React-Versionen wird dieser Aufruf in einem geschützten Ablauf umschlossen, sodass anstelle eines Absturzes der gesamten Seite der Fehler als Fehler in der Commit-Phase erfasst und an die nächstgelegene Error-Boundary weitergeleitet wird. In jedem Fall handelt es sich um einen echten Laufzeitfehler, nicht nur um eine Konsoleintragung, die man ignorieren kann.

    Insgesamt ergibt sich somit ein dreifacher, unterschiedlicher Fehler:

    1. Ihr eigentlicher Aufräumcode wird niemals ausgeführt, weshalb Timer, Event-Listener und Abonnements auch nie deaktiviert werden.
    2. React gibt während der Entwicklung eine Warnung aus.
    3. Später, wenn React versucht, den „Aufräumvorgang“ auszuführen, versucht es, eine Promise wie eine Funktion aufzurufen und wirft dabei einen Fehler aus.

    Eine asynchrone Setup-Funktion umgeht die Regeln nicht heimlich – sie verstößt an zwei verschiedenen Stellen im Lebenszyklus des Komponenten dagegen.

    Teil 4: Vergleich der beiden Ausführungspfade Schritt für Schritt

    Es hilft, eine synchrone Setup-Funktion Moment für Moment mit einer asynchronen zu vergleichen. Die ersten drei Schritte stimmen genau überein. Ab Schritt vier weicht alles ab.

    Synchrone Version:

    1. Die Komponentenfunktion wird ausgeführt und JSX wird berechnet.
    2. React speichert die Änderung ab, und der Browser zeichnet das Ergebnis aus.
    3. React ruft Ihre Setup-Funktion auf.
  • Die Funktion gibt eine echte Aufräumfunktion zurück, die von React gespeichert wird.
  • Jede asynchrone Logik innerhalb des Effects läuft weiter, und Zustandsaktualisierungen werden problemlos angewendet.
  • Wenn sich die Abhängigkeiten ändern oder das Komponenten-Element entmontiert wird, ruft React die gespeicherte cleanup()-Funktion auf.
  • Durch die Aufräumfunktion wird der Zeitintervall aufgehoben, der Listener entfernt oder das Ignorieren-Flag gesetzt – es kommt zu keinen Lecks.
  • Asynchrone Version:

    1. Die Komponentenfunktion wird ausgeführt und JSX wird berechnet.
    2. React führt die Aktualisierung durch, und der Browser zeichnet das Ergebnis aus.
    3. React ruft Ihre Setup-Funktion auf, die – da sie asynchron ist – sofort eine Rückgabe liefert.
    4. Was zurückgegeben wird, ist ein Promise { <pending> }, und React speichert diesen Promise, als wäre es die Aufräumfunktion. Im Entwicklungsmodus tritt hier eine Warnung auf.
    5. Ihre await-Aufrufe werden immer noch im Hintergrund abgewickelt, und jede von Ihnen geschriebene Aufräumlogik befindet sich innerhalb dieses noch nicht abgewickelten Promises und ist daher unerreichbar.
    6. Wenn sich Abhängigkeiten ändern oder das Komponente deinstalliert wird, versucht React, den gespeicherten Wert aufzurufen – der kein Funktionsobjekt ist – und wirft einen TypeError aus.
    7. Es fand nie ein Aufräumen statt. Intervalle feuern weiterhin ab, und veraltete Antworten können immer noch setState auslösen.

    Diese Vergleich klärt außerdem, warum der Vertrag eine synchrone Handhabung erfordert. Die Effekte werden nach dem Commit ausgelöst, sobald die Darstellung bereits erfolgt ist, und die Aufräumarbeiten für den vorherigen Effekt müssen unmittelbar bevor der nächste Effekt ausgelöst wird ablaufen – was bereits bei der folgenden Renderung der Fall sein kann. Wenn React auf eine Promise warten müsste, um herauszufinden, ob eine Aufräumfunktion vorhanden ist, müsste die gesamte Commit-Sequenz aufgrund beliebigen Benutzercodes pausieren, was im Widerspruch zur Konzeption der Commit-Phase steht. Das ist der eigentliche Grund, warum die API um synchrone Rückgabewerte herum konstruiert wurde, anstatt React beizubringen, auf seine Promises zu warten.

    Teil 5: Die Rennbedingung, die dieses Muster verbirgt

    Es gibt eine zweite, subtilere Gefahr beim asynchronen Muster: Ohne eine Aufräumfunktion verlieren Sie jede Möglichkeit, überholte asynchrone Aufgaben abzubrechen oder zu ignorieren. Reacts eigene Dokumentation veranschaulicht dies anhand eines Szenarios einer Profilseite, und es lohnt sich, dieses gut zu verstehen – denn es handelt sich dabei nicht um einen stilistischen Kritikpunkt, sondern um einen Fehler, den Sie in die Produktion einschleusen könnten.

    So verläuft der Ablauf:

    1. Sie klicken auf „Bob“, und die App beginnt, Bobs Biografie abzurufen.
    2. Noch bevor dies abgeschlossen ist, klicken Sie auf „Taylor“, wodurch die Abfrage von Taylors Biografie ausgelöst wird.
    3. Taylors Antwort kommt zuerst an, sodass die Benutzeroberfläche Taylors Biografie anzeigt.
    4. Bobs Antwort, die langsamer kam, erreicht anschließend die App und überschreibt den aktuellen Zustand.
    5. Am Ende zeigt das Bildschirm Taylor als ausgewählten Benutzer an, wobei unterhalb Bobs Biografie angezeigt wird.

    Weil keine Garantie besteht, dass Netzwerkantworten in der Reihenfolge der abgesendeten Anfragen geliefert werden, stellt diese Art von Unstimmigkeit ein echtes Risiko dar. Die gängige Lösung ist eine Aufräumfunktion, die ein ignore-Flag setzt, wodurch alle Antworten außer derjenigen, die mit der neuesten Darstellung verbunden ist, ignoriert werden können. Diese Lösung funktioniert nur dann, wenn Ihre Setup-Funktion synchron zurückgegeben wird und React somit eine echte Aufräumfunktion zur Ausführung erhält.

    Daher stellt der asynchrone Callback nicht nur eine technische Verletzung des API-Vertrags dar – er beseitigt außerdem genau den Mechanismus, der verhindert, dass diese Rennbedingung Ihre Benutzer erreicht.

    Teil 6: Die richtigen Muster

    Jede gültige Lösung basiert auf derselben Idee: Die Setup-Funktion selbst muss synchron bleiben, und die asynchrone Arbeit soll in etwas verlagert werden, was von dieser Funktion aufgerufen wird. Auf diese Weise erhält React weiterhin eine echte Aufräumfunktion.

    Muster 1: Eine benannte asynchrone Funktion innerhalb des Effects (die offizielle Empfehlung)

    Dies ist der Ansatz, der in der React-Dokumentation gezeigt wird, und er beinhaltet die ignore-Flagge, die die oben beschriebene Rennbedingung beseitigt:

    useEffect(() => {
      let ignore = false;
    
      async function startFetching() {
        const json = await fetchTodos(userId);
        if (!ignore) {
          setTodos(json);
        }
      }  startFetching();  return () => {
        ignore = true;
      };
    }, [userId]);
    

    Die äußere Pfeilfunktion berührt niemals await, sodass return () => { ignore = true; } React eine echte Aufräumfunktion zur Verfügung stellt. Die innere benannte Funktion kann hingegen frei darauf warten, was sie benötigt.

    Muster 2: Eine asynchrone IIFE

    Dies erledigt dieselbe Aufgabe, nur mit einer anonymen Funktion, die sofort ausgeführt wird, anstelle einer benannten:

    useEffect(() => {
      let ignore = false;
    
      (async () => {
        const data = await fetchUser(id);
        if (!ignore) setUser(data);
      })();  return () => { ignore = true; };
    }, [id]);
    

    Muster 3: Einfache .then()-Ketten

    Die React-Referenzdokumentation verwendet tatsächlich diesen Stil in ihrem eigenen Beispiel zur Datenerfassung:

    useEffect(() => {
      let ignore = false;
      setBio(null);
      fetchBio(person).then(result => {
        if (!ignore) {
          setBio(result);
        }
      });
      return () => {
        ignore = true;
      };
    }, [person]);
    

    Muster 4: AbortController, wenn Sie eine echte Abbruchfunktion benötigen

    Die ignore-Flagge verhindert, dass eine veraltete Antwort den Zustand überschreibt, stoppt aber nicht die Anfrage selbst daran, im Hintergrund abgeschlossen zu werden. Wenn Sie fetch verwenden, können Sie noch weiter gehen und den Netzwerkaufruf direkt abbrechen. In den React-Dokumentationen wird ausdrücklich darauf hingewiesen, dass die Aufräumarbeiten entweder den Fetch-Aufruf abbrechen oder sein Ergebnis ignorieren sollten:

    useEffect(() => {
      const controller = new AbortController();
    
      (async () => {
        try {
          const res = await fetch(`/api/users/${id}`, {
            signal: controller.signal,
          });
          setUser(await res.json());
        } catch (e) {
          if (e.name !== 'AbortError') throw e;
        }
      })();  return () => controller.abort();
    }, [id]);
    

    Welches davon sollten Sie in einem Interview zuerst ansprechen? Beginnen Sie mit Muster 1 – darauf weisen sowohl die Warnungen im Entwicklungsmodus von React als auch die offiziellen Dokumentationen hin. Nennen Sie explizit die ignore-Flagge und fügen Sie hinzu, dass man bei fetch-Aufrufen noch einen Schritt weitergehen und AbortController verwenden sollte, um die Anfrage abzubrechen anstatt lediglich ihr Ergebnis zu ignorieren. Diese Kombination – Korrektheit, Rennbedingungen sowie verschwendeter Netzwerkverkehr – lässt sich in drei Sätzen darstellen und umfasst das gesamte Problem.

    Teil 7: StrictMode und TypeScript erkennen das ebenfalls

    StrictMode. Während der Entwicklung führt StrictMode absichtlich direkt nach dem initialen Mount einen zusätzlichen Setup-und-Analyse-Zyklus aus. Laut den React-Dokumentationen dient dies dazu, zu überprüfen, ob Ihre Cleanup-Logik tatsächlich der Setup-Logik entspricht. Ein asynchrer Effekt wird aufgrund seiner Natur zwangsläufig diese Überprüfung nicht bestehen, da er niemals eine verwendbare Cleanup-Funktion zurückgibt. Diese erzwungene doppelte Ausführung macht genau dann Doppelabonnements, Doppelintervalle sowie doppelte Fetch-Aufrufe sichtbar, deren beiden Antworten letztendlich im State gespeichert werden.

    TypeScript. Die Typdefinitionen für React integrieren diesen Vertrag direkt in das Typsystem:

    // From @types/react (simplified)
    type Destructor = () => void;
    type EffectCallback = () => void | Destructor;
    
    function useEffect(effect: EffectCallback, deps?: DependencyList): void;
    

    Eine asynchrone Funktion hat die Signatur () => Promise<void>, und diese Typisierung kann einfach nicht an EffectCallback zugewiesen werden. Daher kommt dieser Fehler in einem TypeScript-Projekt gar nicht erst über die Kompilierung hinaus. Das ist an sich bemerkenswert: Die Beschränkung ist so grundlegend, dass sie an drei verschiedenen Stellen durchgesetzt wird – im Typensystem, in Warnungen im Entwicklungsmodus sowie im tatsächlichen Laufzeitverhalten.

    Teil 8: Moderne Alternativen, die erwähnt werden sollten

    Eine ausführlichere Antwort erkennt außerdem an, dass das manuelle Abrufen von Daten innerhalb von Effects nicht mehr als beste Praxis gilt. Die React-Dokumentation weist selbst darauf hin, dass das Direktabrufen von Daten innerhalb von Effects repetitiv wird und es später schwieriger macht, Caching sowie Server-Rendering hinzuzufügen. Stattdessen werden einige Alternativen vorgeschlagen:

    • Framework-ebene Lader. Wenn das Projekt ein Framework wie Next.js oder Remix verwendet, ist sein integrierter Datenabrufmechanismus vorzuziehen statt von client-seitigen Effekten.
    • Client-seitige Cache-Bibliotheken. In den React-Dokumentationen werden ausdrücklich TanStack Query, useSWR und React Router 6.4+ genannt. Diese kümmern sich bereits um die Deduplizierung, das Caching sowie um die oben besprochenen Rennbedingungen.
  • Die use-API, die in React 19 eingeführt wurde. use(promise) ermöglicht es einem Komponenten, den bereits gelösten Wert einer Promise direkt abzurufen, wobei der Komponentenaufruf während des Wartezustands der Promise pausiert wird und Suspense einen Ersatzinhalt anzeigen kann. Eine wichtige Einschränkung, die erwähnt werden sollte: Eine während des Renderings neu erstellte Promise wird bei jedem Render erneut erstellt, sodass use dazu gedacht ist, Promises zu verwenden, die von Server Components übergeben werden oder durch einen Cache verwaltet werden – nicht Rohaufrufe von fetch(), die direkt im Code geschrieben werden.
  • Nichts davon ersetzt die grundlegende Erklärung. Die Frage zum Effect-Contract prüft tatsächlich, ob man versteht, was React intern mit dem zurückgegebenen Wert macht. Doch wenn man mit etwas wie „Und deshalb sollte man für das eigentliche Datenabrufen lieber TanStack Query oder einen Framework-Loader verwenden“ abschließt, zeigt das, dass man auch weiß, wo sich die aktuellen Best Practices tatsächlich befinden.

    Die 30-Sekunden-Interview-Antwort

    Falls man sich nur eine Version dieser Antwort merken soll, dann diese:

    „Die Setup-Funktion von useEffect folgt einem synchronen Vertrag: Sie muss entweder nichts oder eine Cleanup-Funktion zurückgeben, da React diese Cleanup-Funktion unmittelbar vor erneutem Ausführen des Effects sowie immer dann benötigt, wenn das Komponente entmontiert wird. Eine asynchrone Funktion kann diese Anforderung niemals erfüllen, denn asynchrone Funktionen geben unabhängig davon, was in ihnen ausgeführt wird, immer ein Promise zurück. React speichert dieses Promise dann an genau der Stelle, an der eine Cleanup-Funktion hingehört: In der Entwicklung wird die Warnung ‚Es sieht so aus, als hätten Sie useEffect(async () => …) geschrieben‘ protokolliert, die eigentliche Cleanup-Logik erhält nie die Gelegenheit zum Ausführen, und später, wenn React versucht, den gespeicherten Wert wie eine Cleanup-Funktion aufzurufen, tritt ein TypeError auf. In der Praxis zeigt sich dies als leckende Abonnements und Rennbedingungen, denn ohne eine funktionierende Cleanup-Funktion gibt es keine Möglichkeit, einen veralteten Anfrage abzubrechen oder zu ignorieren. Die Lösung besteht darin, eine Cleanup-Funktion bereitzuhalten.“

    Die Callback-Funktion selbst ist synchron – definieren Sie eine asynchrone Funktion darin, rufen Sie diese Funktion auf und geben Sie anschließend eine Aufräumfunktion zurück, die entweder ein Ignorieren-Flag auf true setzt oder die abort-Methode eines AbortController auslöst. Für das Herunterladen von Daten in der Praxis ist ein Framework-Loader oder eine Bibliothek wie TanStack Query die bessere Wahl, da diese solche Wettbewerbsbedingungen bereits automatisch handhaben.

    Diese Antwort berührt den Vertrag, die zugrunde liegende JavaScript-Regel, beide konkreten Fehlermöglichkeiten, die Wettbewerbsbedingung, die akzeptierte Lösung sowie den Stand des modernen Ökosystems. Sie signalisiert dem Interviewer, dass das Konzept nicht nur als Regel auswendig gelernt wurde – vielmehr wird der dahinterliegende Mechanismus verstanden, was genau mit einer solchen Frage bezweckt wird.