Startseite / Artikel / Instabile useEffect-Abhängigkeiten: Diagnose von Akkulaufzeitproblemen in React Native

Instabile useEffect-Abhängigkeiten: Diagnose von Akkulaufzeitproblemen in React Native

Sehen Sie, wie eine Objektabhängigkeit sowie ein fehlendes Abhängigkeitsarray zu Akkulaufzeitverlust und Ruckeln in React Native führten, wie man dies profilieren kann, sowie die drei funktionierenden Lösungen.

2423 Wörter

Ein Echtzeit-Bildschirm, der im Simulator perfekt aussieht, kann dennoch mit einem Fehler ausgeliefert werden, der Handys überhitzt und Animationen zum Stocken bringt – ohne dass dabei ein einziger Fehler protokolliert wird. Der übliche Übeltäter ist ein useEffect, dessen Abhängigkeiten weitaus häufiger ändern als die Daten, um die es geht. Diese Fallstudie zeigt einen solchen Fehler auf einem Live-Tracking-Bildschirm: Warum useEffect existiert, wann es das richtige Werkzeug ist, wie zwei kleine Fehler bei den Abhängigkeiten zu einem Render-Loop führten, wie das Problem mithilfe von Profilverfahren auf der JavaScript- sowie der nativen Seite aufgespürt wurde, und die drei Änderungen, die es behoben haben.

Warum useEffect existiert

Vor React 16.8 verteilten Klassenkomponenten Nebeneffekte wie API-Aufrufe, Abonnements und Timer auf drei separate Lebenszyklusmethoden: componentDidMount, componentDidUpdate und componentWillUnmount. Ein logisches Problem, beispielsweise „auf diesen Socket hören, solange der Bildschirm angezeigt wird“, wurde in der Regel auf alle drei Methoden aufgeteilt, was den zusammenhängenden Code zerstreute und dazu führte, dass leicht ein Schritt vergessen werden konnte.

Hooks kamen mit React 16.8 auf den Markt, und useEffect wurde entwickelt, um diese Logik zu vereinheitlichen. Anstatt über Lebenszyklusphasen nachzudenken, beschreibt man, wie die Komponente mit einem externen System synchron bleibt – egal ob es sich um eine Anfrage, einen nativen Zuhörer, ein Abonnement, einen Timer oder einen Animationsschritt handelt. Der Effekt wird nach dem Render ausgeführt, kann eine Aufräumfunktion zurückgeben und jedes Mal erneut ausgeführt werden, wenn sich ein Wert in seinem Abhängigkeitsarray ändert:

useEffect(() => {
  // side effect code
return () => {
    // cleanup code
  };
}, [dependencies]);

In React Native taucht der Hook ständig auf, da fast alle Aufgaben außerhalb der Renderung als Nebeneffekte gelten: AppState- und NetInfo-Listener, Keyboard-Ereignisse, Standortüberwacher, WebSocket-Verbindungen sowie native SDKs für Funktionen wie die Kamera oder Bluetooth.

Warum Disziplin lohnenswert ist

Nebeneffekte benötigen einen Ort, an dem sie nach Abschluss der Renderung ausgeführt werden können, sowie einen Ort, an dem sie vor dem Entfernen des Components oder vor erneuter Ausführung des Effekts wieder deaktiviert werden. Ohne diese Struktur entstehen leckende Listener, doppelte Abonnements und veraltete Schließfunktionen – Probleme, die weitaus teurer sind als die Nutzung von useEffect, wenn es richtig angewendet wird.

Wann useEffect verwendet werden sollte und wann nicht

Gute Anwendungsfälle in einer React Native-App sind unter anderem:

  • Das Anhören nativer Ereignisquellen wie AppState, NetInfo, Keyboard oder Dimensions
  • Das Ausführen von Timern, Intervallen oder Animationsschleifen nur dann, wenn ein Bildschirm sichtbar ist
  • Das Abstimmen des lokalen Zustands auf eine Eigenschaft oder einen globalen Speicher, sobald die Darstellung abgeschlossen ist
  • Das Laden von Daten beim Initialisieren oder erneut, wenn eine Eingabe wie die ID des aktuellen Benutzers sich ändert
  • Das imperative Steuern eines nativen Moduls, beispielsweise das Ein- und Ausschalten der Standortverfolgung, von BLE-Scans oder eines Kamerastrums

Situationen, in denen ein Effect das falsche Werkzeug ist:

  • Das Ableiten eines Wertes aus Eigenschaften oder Zuständen. Berechnen Sie ihn stattdessen während der Darstellung.
  • Die Reaktion auf eine Benutzeraktion wie einen Buttonklick. Behandeln Sie dies im Ereignishandler, nicht in einem Effect, der auf Zustandsänderungen wartet.
  • „Warten“ auf eine Aktualisierung des Zustands. Das bedeutet in der Regel, dass zwei separate Zustandswerte miteinander kombiniert werden sollten.
  • Auslassung des Abhängigkeitsarrays oder Abhängigkeit von einem Objekt bzw. Array, das bei jeder Neuansicht neu erstellt wird. Das ist genau die Falle, die im weiteren Verlauf dieses Artikels beschrieben wird.
  • Eine ausführlichere Behandlung dieses Anti-Patterns zur Synchronisierung finden Sie in unserem Artikel warum die Synchronisierung des Zustands mit useEffect riskant ist.

    Der Fehler: Ein Tracking-Interface, das die Batterie leerfraß

    Stellen Sie sich ein Live-Tracking-Interface für Lieferungen vor: Eine Karte, auf der die Position des Fahrers in Echtzeit aktualisiert wird – ähnlich wie bei einer Essenslieferungs-App. Es funktionierte im Simulator, bestand die Qualitätskontrolle auf zwei oder drei Geräten und wurde veröffentlicht. Etwa zwei Wochen später trafen erste Support-Anfragen ein:

    • Auf Android berichteten Nutzer, dass das Telefon heiß wurde und die Akkuleistung innerhalb von 20 Minuten nach dem Anzeigen des Tracking-Bildschirms um etwa 15 % abnahm.
    • Auf iOS berichteten Nutzer, dass die Karte ruckelte: Der Fahrermarker sprang zwischen Positionen hin und her, anstatt sich gleichmäßig zu bewegen, und das Scrollen war träge.

    Zwei verschiedene Symptome hatten letztendlich denselben Ursprung.

    Die Komponente, vereinfacht

    Hier ist eine vereinfachte Version des Bildschirms. Sie speichert die Position des Fahrers sowie den Auftrag im Zustand, erstellt eine Socket-Verbindung, abonniert in einem Effekt Standortaktualisierungen und berechnet in einem anderen Effekt die voraussichtliche Ankunftszeit neu. Die beiden markierten Zeilen zeigen an, wo es zu Problemen kam:

    function TrackingScreen({ orderId }) {
      const [driverLocation, setDriverLocation] = useState(null);
      const [order, setOrder] = useState(fetchOrderSync(orderId)); // returns a new object reference
      const socket = useMemo(() => connectSocket(), []); // looked memoized, wasn't the issue
    
      useEffect(() => {
        const subscription = LocationSocket.on('update', (loc) => {
          setDriverLocation(loc);
        });
        return () => subscription.remove();
      }, [order]); // 🚩 the bug
      useEffect(() => {
        console.log('Recalculating ETA...');
        calculateETA(order, driverLocation);
      }); // 🚩 no dependency array at all
      return <Map driverLocation={driverLocation} order={order} />;
    }
    

    Zwei unabhängige Probleme lagen übereinander.

    1. order erhielt jedes Mal eine neue Objektidentität, wenn der Elternteil gerendert wurde. In der echten Anwendung stammte diese von einem Hook weiter oben im Baum, der jedes Mal Eigenschaften in ein neues Objekt übertrug. (Der vereinfachte Auszug zeigt, dass sie von useState stammt, was eigentlich eine stabile Referenz beibehalten würde; betrachten Sie diese Zeile als Ersatz für den upstream Hook.) Da der Subscription-Effekt order als Abhängigkeit aufgelistet hatte, führte React nach jedem Render die Aufräumarbeiten durch und abonnierte sich erneut beim Location-Socket, nicht nur dann, wenn sich der Auftrag tatsächlich änderte.
  • Der ETA-Effekt hatte überhaupt keine Abhängigkeitsmatrix. Ein Effekt ohne solche Matrix wird nach jeder Renderung ausgeführt, einschließlich der durch setDriverLocation innerhalb des ersten Effekts ausgelösten Renderungen. In dieser App führte die ETA-Berechnung außerdem zu einer Zustandsaktualisierung an anderer Stelle, was einen Kreislauf schloss: Standortaktualisierung, erneute Renderung, ETA-Effekt, weitere Zustandsaktualisierung, weitere Renderung und so weiter.
  • Derselbe Code verursachte je nach Plattform unterschiedliche Symptome. Auf Android wurde die Verbindung ständig schnell wiederhergestellt und getrennt, wodurch Radio und CPU fast kontinuierlich beschäftigt blieben; das war die eigentliche Ursache für den Akkustromverbrauch. Auf iOS wurde die Radioaktivität anders begrenzt, doch der ständige Abonnements- und Renderzyklus belastete weiterhin den JavaScript-Thread und ließ die Karte ihren Markerlayer weitaus häufiger neu laden, als nötig wäre – was die Nutzer als Stockungen wahrnahmen.

    Eine Anmerkung zum Auszug: useState(fetchOrderSync(orderId)) ruft fetchOrderSync bei jeder Neuzeichnung auf, obwohl React das Ergebnis nur beim ersten Mal verwendet. Wenn der Anfangswert aufwendig zu berechnen ist, sollte stattdessen eine Funktion übergeben werden, wie in useState(() => fetchOrderSync(orderId)), damit sie nur einmal ausgeführt wird.

    Wie das Problem diagnostiziert wurde

    Schritt 1: Überprüfen, ob es zu Neuzeichnungen kommt und nicht zur Problematik der Karte

    Der erste Verdächtige war natürlich das Karten-SDK. Der React DevTools Profiler, der über dieselbe Metro-Verbindung wie bei der Entwicklung mit einer React Native-Anwendung verbunden wird, schloss dies schnell aus. Das Team erstellte ein 10-sekündiges Profil, während der Tracking-Bildschirm ohne jegliche Interaktion angezeigt wurde.

    Die Aufzeichnung zeigte, dass der Komponentenbaum Dutzende von Renderungen pro Sekunde ausführte, während die Backend-Plattform nur alle etwa 3 bis 5 Sekunden eine neue Fahrzeugposition sendete. Dieser Unterschied war der erste echte Hinweis. Als Faustregel sollte die Renderfrequenz bedeutungsvollen Datenänderungen folgen; wenn die Anzahl der Renderungen um ein Vielfaches höher ist als die der Updates, wird etwas sie künstlich auslösen.

    Schritt 2: Ermitteln, warum er neu gerendert wird

    Die sortierte Ansicht des Profilers listete TrackingScreen und Map auf, die nacheinander immer wieder gerendert wurden. Um den genauen Auslöser zu erkennen, wurde vorübergehend die kleine Debugging-Bibliothek why-did-you-render hinzugefügt. Sie protokolliert, welche Eigenschafts- oder Zustandsänderung zu jeder Renderung führte, und berichtete Folgendes:

    TrackingScreen re-rendered because of changed props: order
    order: Object !== Object (deep equal: true)
    

    Der Teil „deep equal: true“ war der entscheidende Beweis. Der Inhalt von order hatte sich auf keine bedeutungsvolle Weise geändert; nur seine Referenz hatte sich geändert, weil das Objekt bei jedem Durchlauf weiter oben neu erstellt wurde. React vergleicht Abhängigkeiten mit Object.is, wodurch ein strukturell identisches, aber neu erstelltes Objekt stets als Änderung gilt.

    Schritt 3: Die native Seite beobachten

    JavaScript-Profiling erklärt die Darstellungen, nicht jedoch, was die Netzwerkhardware des Geräts tut. Auf der nativen Seite wurde Flipper mit seinem Network-Plugin sowie einem benutzerdefinierten Logging-Plugin verwendet, um den Lebenszyklus von WebSocket zu überwachen. Die Protokolle zeigten wiederholte connect- und close-Ereignisse, die nur wenige Sekunden voneinander entfernt auftraten, anstatt eine dauerhafte Verbindung solange zu bestehen, wie der Bildschirm geöffnet war. Das bestätigte, dass die Verbindung jedes Mal abgebrochen wurde, wenn der Effekt erneut ausgeführt wurde.

    Flipper’s Hermes-Debugger lieferte eine weitere Bestätigung: Ein Breakpoint, der in der Cleanup-Funktion des Abonnement-Effekts platziert wurde, wurde weitaus häufiger ausgelöst, als es durch eine echte Entfernung oder Änderung der Bestellung erklärbar gewesen wäre.

    Eine zeitbezogene Einschränkung: Neuere React Native-Versionen haben Flipper als Standard-Debugging-Tool zugunsten der React Native DevTools aufgegeben, daher sollten Sie die aktuelle React Native-Dokumentation prüfen, um die für Ihre Version empfohlene Einrichtung zu finden. Der Ansatz, das Verbindungslebenzyklus zu beobachten und in Cleanup-Funktionen Breakpoints einzufügen, gilt unabhängig von den verwendeten Tools.

    Schritt 4: Den tatsächlichen Einfluss auf Akku und CPU messen

    Schließlich quantifizierte der Profiler in Android Studio mithilfe seiner CPU- und Energieansichten zusammen mit Flipper den Schaden:

    • Vor der Korrektur: eine anhaltende CPU-Nutzung von etwa 35 bis 40 Prozent, obwohl der Überwachungsbildschirm inaktiv war, und der Energieprofiler klassifizierte die App aufgrund ständiger Funkaktivität als starken Akkuverbraucher.
    • Nach der Korrektur: die CPU-Nutzung im Inaktivzustand sank auf etwa 4 bis 6 Prozent, und der Energieprofiler meldete keine anhaltende Funknutzung mehr. Stattdessen wurden kurze, periodische Ausbrüche angezeigt, die mit dem tatsächlichen Aktualisierungsintervall übereinstimmten.

    Die Korrektur: drei gezielte Änderungen

    Jede Änderung behebt ein Problem in der Kette.

    1. Verwendung einer Primitivvariablen statt eines Objekts

    Die Abonnementfunktion muss nur dann neu gestartet werden, wenn sich die Bestellung selbst ändert – und der orderId-String identifiziert dies genau. Da Primitivvariablen nach ihrem Wert verglichen werden, bleibt dieser bei jeder Darstellung gleich:

    useEffect(() => {
      const subscription = LocationSocket.on('update', (loc) => {
        setDriverLocation(loc);
      });
      return () => subscription.remove();
    }, [orderId]); // orderId is a primitive string — stable across re-renders
    

    2. Abhängigkeiten für jeden Effekt deklarieren

    Verzichten Sie auf das Array nur dann, wenn Sie tatsächlich möchten, dass die Auswirkung nach jeder Renderung ausgeführt wird – was selten der Fall ist. Hier sollte die ETA neu berechnet werden, wenn sich die Position des Fahrers ändert:

    useEffect(() => {
      calculateETA(order, driverLocation);
    }, [driverLocation]); // only recalculate when location actually changes
    

    Streng genommen liest die Funktion auch order ein, weshalb die Lint-Regel von react-hooks/exhaustive-deps nach diesem Wert im Array fragt. Sobald order gememorisert wird (im nächsten Fix), ist es sicher, ihn hinzuzufügen – dadurch bleibt die Funktion zuverlässig, da sie nur dann erneut ausgeführt wird, wenn sich die Reihenfolge tatsächlich ändert. Das Auslassen eines Werts, den die Funktion liest, birgt das Risiko, die ETA auf veralteten Daten zu berechnen.

    3. Das Order-Objekt weiter oben gememorisieren

    Zum Schluss sollten Sie das Objekt dort stabilisieren, wo es erstellt wird, sodass sich seine Referenz nur dann ändert, wenn sich die relevanten Felder ändern:

    const order = useMemo(() => buildOrder(rawOrderData), [rawOrderData.id, rawOrderData.status]);
    

    Seien Sie bei dieser Abhängigkeitsliste sorgfältig: Da darin nur id und status enthalten sind, führt eine Änderung eines anderen Feldes von rawOrderData, wie beispielsweise der Lieferadresse, nicht zur Erstellung einer neuen order. Das gilt nur dann, wenn nichts weiter davon abhängt.

    Nach Durchführung aller drei Änderungen – wobei die Verbindung über das Socket pro Besuch der Seite nur einmal hergestellt wurde, die ETA nur dann neu berechnet wurde, wenn sich der Standort tatsächlich bewegte, und die Renderrate von Dutzenden pro Sekunde auf etwa eins alle paar Sekunden sank, was dem tatsächlichen Datenfluss entspricht –

    Lektionen für React Native-Teams

    • Gehen Sie davon aus, dass Abhängigkeiten zu Objekten und Arrays instabil sind. Sofern Sie sie nicht mit useMemo oder useCallback erstellt haben, sollten Sie bei jeder Renderung mit einer neuen Referenz rechnen. Verwenden Sie lieber primitive Abhängigkeiten wie IDs, sofern diese den gewünschten Zweck erfüllen.
    • Schreiben Sie immer die Abhängigkeitsarray und deaktivieren Sie nicht die Lint-Regel. react-hooks/exhaustive-deps dient genau dazu, solche Fehler zu erkennen. Das Deaktivieren dieser Regel ohne Verständnis der Warnung führt dazu, dass solche Probleme in die Produktion gelangen; beheben Sie stattdessen die Instabilität.
    • Profilieren Sie auch inaktive Bildschirme, nicht nur Interaktionen. Dieser Fehler trat nur auf, wenn niemand den Bildschirm berührte – genau diesen Zustand übersieht man bei manuellem Testen oft.
  • Akku-Entladung und Ruckler können derselbe Fehler sein. Das Verhalten des Radios sowie der CPU in Android führt zu einem Akkuproblem, während iOSs Render-Pipeline denselben Ursachenfaktor in Ruckler umwandelt.
  • Berücksichtigen Sie beide Aspekte der App. Ein JavaScript-Profiler zeigt die Rendervorgänge und ihre Ursachen an; native Tools zeigen Verbindungen, Radio-Nutzung sowie Energieverbrauch. Ein solcher Fehler erfordert in der Regel beide Ansätze, um zuverlässig diagnostiziert werden zu können.
  • Falls Sie sich weiter mit dem Rendering beschäftigen möchten, behandelt unsere Übersicht zu gängigen Mustern, die unnötige React-Re-Renderungen auslösen entsprechende Fallstricke.

    Zusammenfassung

    Nur wenige Hooks lassen sich so schnell wie useEffect schreiben, und nur wenige sind so anfällig dafür, heimlich falsch verwendet zu werden. Auf Echtzeitbildschirmen führt ein Fehler bei den Abhängigkeiten nicht nur zu einer zusätzlichen Logzeile – er kann außerdem den JavaScript-Thread überlasten und dazu führen, dass die App ohne sichtbaren Fehler unbrauchbar erscheint. Stabile Abhängigkeiten, explizite Arrays sowie die Analyse von inaktiven Zuständen sind kostengünstige Gewohnheiten, die die teuersten Folgen dieses Fehlers verhindern.

    Verwandte Artikel

  • Ein fehlerresistenter PDF-Reader für große Dokumente in React Native entwickeln — Lernen Sie eine Architektur zum Darstellen riesiger, mehrtausendseitiger PDF-Dateien in React Native, einschließlich verzögerter Lesezeichenstrukturen, Suchfunktionen sowie stabiler Navigation auf Geräten mit geringer Leistung.
  • Flutter vs React Native: Die Entscheidungen, die erst in der Produktion zutage treten — Ein fundierter Vergleich von Flutter und React Native, der auf Benchmark-Wettbewerben verzichtet und sich stattdessen auf das Darstellen von Inhalten, die Programmiersprache, den Zustand, Listen sowie das Ökosystem konzentriert, je mehr sich die App entwickelt.
  • Planung eines Expo SDK 58-Updates: iOS 27, React Native 0.88 und neue Werkzeuge — Eine praktische Übersicht über die Expo SDK 58 Beta: Welche Änderungen gibt es für iOS 27 und React Native 0.88, welche Funktionen sind noch experimentell und wie kann das Update sicher getestet werden.
  • Die richtige React-Werkzeugwahl: Derive, Handle, Fetch, Defer oder Effect — Ein Entscheidungshilfe-Handbuch zur Ersetzung von reflexiven useEffect-Aufrufen durch abgeleitete Werte, Ereignishandler, eine Datenschicht, useTransition, measured useMemo sowie React 19-APIs.
  • Lesen von React Performance Tracks in Chrome DevTools, um langsame Renderungen zu finden — Erfahren Sie, wie die in React 19.2 hinzugefügten Scheduler-, Components- und Server-Tracks zeigen, wo eine langsame Interaktion ihre Zeit verbringt, sowie ein Record-Fix-Record-Arbeitsablauf, um darauf zu reagieren.