Dépendances instables d’useEffect : diagnostic de la consommation excessive de batterie dans React Native
Voyez comment une dépendance d’objet et un tableau de dépendances manquantes ont provoqué une consommation accrue de la batterie ainsi que des ralentissements dans l’affichage des cartes dans React Native, comment les diagnostiquer, et les trois solutions qui ont fonctionné.
Un écran en temps réel qui semble parfait dans le simulateur peut encore être livré avec une erreur qui surchauffe les téléphones et provoque des ralentissements dans les animations, sans qu’aucune erreur ne soit enregistrée. La cause habituelle est un useEffect dont les dépendances changent bien plus souvent que les données qui l’intéressent. Cette étude de cas décrit une telle erreur sur un écran de suivi de livraison en direct : pourquoi useEffect existe, quand c’est l’outil adapté, comment deux petites erreurs de dépendance se sont combinées pour créer un boucle de rendu, comment le problème a été identifié à l’aide d’outils de profilage tant du côté JavaScript que natif, ainsi que les trois modifications qui l’ont résolu.
Pourquoi useEffect existe
Au préalable à React 16.8, les composants de type class dispersaient des effets secondaires tels que les appels API, les abonnements et les temporisateurs dans trois méthodes distinctes du cycle de vie : componentDidMount, componentDidUpdate et componentWillUnmount. Une exigence logique, par exemple « écouter ce socket tant que l’écran est affiché », était généralement répartie sur ces trois méthodes, ce qui dispersait le code pertinent et rendait facile l’oubli d’une étape.
Les hooks sont apparus avec React 16.8, et useEffect a été conçu pour unifier cette logique. Au lieu de se concentrer sur les étapes du cycle de vie, on décrit comment le composant reste en synchronisation avec un système externe, qu’il s’agisse d’une requête, d’un écouteur natif, d’un abonnement, d’un temporisateur ou d’une frame d’animation. L’effet s’exécute après le rendu, peut retourner une fonction de nettoyage et se réexécute chaque fois qu’une valeur de son tableau de dépendances change :
useEffect(() => {
// side effect code
return () => {
// cleanup code
};
}, [dependencies]);
Dans React Native, les hooks apparaissent constamment, car presque tout ce qui se fait en dehors du rendu est considéré comme un effet secondaire : les écouteurs AppState et NetInfo, les événements Keyboard, les surveillants de localisation, les connexions WebSocket, ainsi que les SDK natifs pour des fonctionnalités comme l’appareil photo ou Bluetooth.
Pourquoi il vaut la peine d’adopter cette discipline
Les effets secondaires ont besoin d’un endroit où s’exécuter une fois le rendu terminé, et d’un endroit pour être désactivés avant que le composant ne soit démonté ou avant que l’effet ne s’exécute à nouveau. Sans cette structure, on se retrouve avec des écouteurs non désactivés, des abonnements dupliqués et des closures obsolètes, et ces problèmes coûtent bien plus cher que l’utilisation correcte de useEffect.
Quand utiliser useEffect, et quand ne pas l’utiliser
Les bonnes utilisations dans une application React Native incluent :
- Écouter des sources d’événements natives telles que
AppState,NetInfo,KeyboardouDimensions - Exécuter des compteurs à retardement, des intervalles ou des boucles d’animation uniquement lorsque l’écran est visible
- Aligner l’état local avec une propriété ou un stockage global une fois le rendu terminé
- Charger des données au moment du montage, ou à nouveau lorsque des informations saisies telles que l’ID de l’utilisateur actuel changent
- Gérer un module natif de manière impérative, par exemple pour activer ou désactiver le suivi de la localisation, les scans BLE ou une diffusion vidéo
Situations où un effet n’est pas l’outil adapté :
- Dériver une valeur à partir de propriétés ou d’état. Calculez-la plutôt pendant le rendu.
- Réagir à une action de l’utilisateur telle que la pression sur un bouton. Gérez-la dans le gestionnaire d’événements, et non dans un effet qui surveille les changements d’état.
Pour une analyse plus approfondie de ce anti-pattern de synchronisation, consultez notre article sur pourquoi synchroniser l’état avec useEffect est risqué.
Le bug : une interface de suivi qui a épuisé la batterie
Imaginez une interface de suivi en temps réel des livraisons : une carte affichant la position du chauffeur qui se met à jour en continu, tout comme dans une application de livraison de nourriture. Elle fonctionnait dans le simulateur, a passé les tests de qualité sur deux ou trois appareils et a été mise en production. Environ deux semaines plus tard, les tickets de support ont commencé à arriver :
- Au niveau d’Android, les utilisateurs ont indiqué que le téléphone devenait chaud et que la batterie baissait d’environ 15 % en 20 minutes seulement, le écran de suivi étant resté actif.
- Au niveau d’iOS, les utilisateurs ont rapporté que la carte fonctionnait de manière saccadée : le marqueur du conducteur sautait d’une position à l’autre au lieu de se déplacer en continu, et le scroll était lent.
Ces deux symptômes différents présentaient en réalité une même cause racine.
Le composant, simplifié
Voici une version simplifiée de l’écran. Il conserve en mémoire la localisation du conducteur ainsi que l’état de la commande, établit une connexion socket, s’abonne aux mises à jour de localisation dans un effet, et recalcule l’heure d’arrivée prévue dans un autre. Les deux lignes soulignées indiquent où se sont produites les erreurs :
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} />;
}
Deux problèmes indépendants s’étaient empilés les uns sur les autres.
orderrecevait une identité d’objet nouvelle chaque fois que le parent était rendu. Dans l’application réelle, cette identité provenait d’un hook situé plus haut dans la hiérarchie qui créait un nouvel objet à chaque fois en diffusant les propriétés. (Le fragment simplifié montre qu’elle provient deuseState, qui conserverait en réalité une référence stable ; considérez cette ligne comme un substitut pour le hook en amont.) Comme l’effet de souscription listaitordercomme dépendance, React exécutait la fonction de nettoyage et se réinscrivait sur le socket de localisation après chaque rendu, et non seulement lorsque l’ordre changeait réellement.
setDriverLocation au sein du premier effet. Dans cette application, le calcul de l’ETA provoquait également une mise à jour d’état ailleurs, ce qui créait un cercle vicieux : mise à jour de la localisation, nouveau rendu, effet ETA, nouvelle mise à jour d’état, nouveau rendu, et ainsi de suite.Le même code produisait des symptômes différents selon la plateforme. Sur Android, le socket se déconnectait et se reconnectait en succession rapide, ce qui maintenait la radio et le CPU occupés presque constamment ; c’était là la véritable cause de l’épuisement de la batterie. Sur iOS, l’activité radio était régulée différemment, mais le cycle constant de réabonnement et de rendu continuait de solliciter fortement la thread JavaScript, ce qui obligeait la carte à réafficher sa couche de marqueurs bien plus souvent que nécessaire, ce que les utilisateurs percevaient comme des saccades.
Une remarque supplémentaire sur ce fragment : useState(fetchOrderSync(orderId)) appelle fetchOrderSync à chaque rendu, même si React n’utilise le résultat que la première fois. Si la valeur initiale est coûteuse en ressources, il convient de passer une fonction à la place, comme dans useState(() => fetchOrderSync(orderId)), afin qu’elle s’exécute une seule fois.
Comment le problème a été diagnostiqué
Étape 1 : Vérifier s’il s’agit de re-rendus, et non du library map
Le premier suspect évident était le SDK map. Le Profilleur des outils de développement React, qui se connecte à une application React Native via la même connexion Metro utilisée pour le développement, a rapidement éliminé cette possibilité. L’équipe a enregistré un profil sur 10 secondes pendant que l’écran de suivi était affiché sans aucune interaction.
La vidéo montrait que l’arbre de composants effectuait des dizaines de rendus par seconde, tandis que le backend n’envoyait une nouvelle localisation de pilote qu’environ tous les 3 à 5 secondes. Ce décalage constituait le premier indice concret. En règle générale, la fréquence des rendus devrait suivre les changements significatifs de données ; lorsque le nombre de rendus dépasse de loin celui des mises à jour, quelque chose les déclenche artificiellement.
Étape 2 : Déterminer pourquoi les rendus sont répétés
L’affichage classé du profileur indiquait que TrackingScreen et Map étaient mis à jour l’un juste après l’autre, encore et encore. Pour identifier le déclencheur exact, la petite bibliothèque de débogage why-did-you-render a été ajoutée temporairement. Elle enregistre quel changement de propriété ou d’état a provoqué chaque rendu, et elle a rapporté ce qui suit :
TrackingScreen re-rendered because of changed props: order
order: Object !== Object (deep equal: true)
Le critère « deep equal: true » a été la preuve décisive. Le contenu de order n’avait pas changé de manière significative ; seul son référentiel avait changé, car l’objet était reconstruit en amont à chaque itération. React compare les dépendances avec Object.is, de sorte qu’un objet structuralement identique mais nouvellement créé est toujours considéré comme un changement.
Étape 3 : Observer le côté natif
Le profilage JavaScript explique les rendus, mais pas ce que fait le matériel réseau du dispositif. Du côté natif, Flipper, avec son plugin Network et un plugin d’affichage personnalisé, a été utilisé pour observer le cycle de vie du WebSocket. Les journaux ont montré des événements connect et close répétés à seulement quelques secondes d’intervalle, plutôt qu’une connexion stable tant que l’écran était ouvert. Cela a confirmé que le socket était déconnecté chaque fois que l’effet était exécuté à nouveau.
Le débogueur Hermes de Flipper a fourni une confirmation supplémentaire : un point d’arrêt placé dans la fonction de nettoyage de l’effet de souscription s’exécutait bien plus fréquemment que ce qu’une véritable désinstallation ou un changement de commande aurait pu expliquer.
Précaution liée au temps : les versions plus récentes de React Native ont abandonné Flipper en tant qu’outil de débogage par défaut au profit des React Native DevTools ; vérifiez donc la documentation actuelle de React Native pour connaître la configuration recommandée pour votre version. Cette méthode, qui consiste à observer le cycle de vie des connexions et à placer des points d’arrêt dans les fonctions de nettoyage, s’applique quel que soit l’outil utilisé.
Étape 4 : Mesurer l’impact réel sur la batterie et le CPU
Finalement, le Profiler d’Android Studio, en utilisant ses vues CPU et Énergie en complément de Flipper, a permis d’quantifier les dommages causés :
- Au préalable de la correction : une utilisation continue du CPU d’environ 35 à 40 % alors que l’écran de suivi était inactif, et le profileur d’énergie classait l’application comme une consommatrice importante de batterie en raison d’une activité radio constante.
- Après la correction : l’utilisation du CPU en mode inactif est passée à environ 4 à 6 %, et le profileur d’énergie ne signale plus d’activité radio continue. Il affiche désormais des pics courts et périodiques correspondant à l’intervalle réel de mise à jour.
La correction : trois modifications ciblées
Chaque modification vise un maillon de la chaîne.
1. S’appuyer sur une primitive plutôt qu’un objet
La souscription n’a besoin de se redémarrer que lorsque l’ordre lui-même change, et la chaîne de caractères orderId identifie précisément cela. Comme les primitives sont comparées par valeur, leur contenu reste inchangé lors de chaque rendu :
useEffect(() => {
const subscription = LocationSocket.on('update', (loc) => {
setDriverLocation(loc);
});
return () => subscription.remove();
}, [orderId]); // orderId is a primitive string — stable across re-renders
2. Déclarer les dépendances pour chaque effet
Omettez l’array uniquement lorsque vous souhaitez vraiment que l’effet s’exécute après chaque rendu, ce qui est rare. Ici, le calcul de l’ETA doit être réexécuté lorsque la position du conducteur change :
useEffect(() => {
calculateETA(order, driverLocation);
}, [driverLocation]); // only recalculate when location actually changes
Stricto sensu, l’effet lit également order, donc la règle de linting react-hooks/exhaustive-deps exigera sa présence dans l’array. Une fois order mémorisé (dans la prochaine correction), l’ajouter est sûr et permet à l’effet de rester fiable, car il ne sera réexécuté que lorsque l’ordre change réellement. Omettre une valeur lue par l’effet risque de faire calculer l’ETA à partir de données obsolètes.
3. Mémoriser l’objet order en amont
Finalement, stabilisez l’objet à son point de création, afin que sa référence ne change que lorsque les champs importants changent :
const order = useMemo(() => buildOrder(rawOrderData), [rawOrderData.id, rawOrderData.status]);
Faites preuve de prudence concernant cette liste de dépendances : puisqu’elle ne contient que id et status, toute modification d’un autre champ de rawOrderData, comme l’adresse de livraison, ne générera pas un nouveau order. Cela n’est valable que si rien en aval ne dépend de ces autres champs.
Avec les trois modifications mises en place — le socket se connectant une seule fois par visite à l’écran, le délai d’arrivée étant recalculé uniquement lorsque la localisation change réellement, et le taux de rendu passant de dizaines par seconde à environ un par quelques secondes, en accord avec le flux réel des données —
Leçons pour les équipes React Native
- Partez du principe que les dépendances de type objet et tableau sont instables. À moins de les avoir créées avec
useMemoouuseCallback, attendez-vous à obtenir une nouvelle référence à chaque rendu. Préférez des dépendances primitives comme les IDs lorsque cela correspond bien à l’intention du code. - Écrivez toujours l’array des dépendances, et ne désactivez pas la règle de vérification.
react-hooks/exhaustive-depsexiste justement pour détecter ce type de bug. Le désactiver sans comprendre l’avertissement est la cause pour laquelle ces problèmes parviennent en production ; corrigez plutôt l’instabilité. - Analyssez les écrans inactifs, pas seulement les interactions. Ce bug n’est apparu que lorsque personne ne touchait à l’écran, ce qui est précisément un état que les tests manuels ont tendance à négliger.
Si vous souhaitez approfondir le côté du rendu, notre aperçu des patterns courants qui provoquent des re-rendus inutiles de React aborde les pièges associés.
Conclusion
Peu d’hooks sont aussi faciles à écrire que useEffect, et peu sont aussi sujets à des erreurs furtives. Sur les écrans en temps réel, une erreur de dépendance ne se limite pas à ajouter une ligne supplémentaire dans le journal : elle peut maintenir le processeur actif, surcharger la thread JavaScript et faire en sorte que l’application semble défectueuse sans erreur visible. Des dépendances stables, des tableaux explicites et un suivi des états inactifs sont de bonnes habitudes qui permettent d’éviter la version la plus coûteuse de ce problème.
Lectures complémentaires
- Les fuites de mémoire dans React Native : suivi du heap JavaScript et des propriétaires de la mémoire native — Découvrez pourquoi le collecteur de déchets ne peut pas protéger une application React Native des fuites natives, et comment identifier ce qui maintient en vie les callbacks, les objets JSI et les images décodées.