Faites moins de travail en premier : une liste de contrôle pour les performances de React avant la mémorisation
Réduisez les coûts de l’application React en évitant le travail inutile : utilisez des délais d’attente pour les recherches, ajoutez une pagination, placez l’état de manière coordonnée, utilisez des clés stables, déplacez le traitement du côté serveur et chargez de manière différée, puis mémorisez si nécessaire.
Lorsqu’une application React semble lente, on a tendance à recourir à useMemo, useCallback ou React.memo. Ces outils permettent d’optimiser le travail existant, mais dans de nombreuses applications, le véritable coût provient d’opérations qui n’étaient pas nécessaires : requêtes redondantes, en-têtes de données trop volumineux, mises à jour d’état qui affectent une grande partie de l’application, ainsi que du code que les utilisateurs n’ont pas encore demandé. Ce guide présente sept moyens d’éliminer ces opérations inutiles avant d’optimiser ce qui reste, accompagnés d’une liste de contrôle à utiliser lors des revues de code.
La question clé tout au long est simple : cette opération peut-elle être entièrement évitée ?
Faire moins de requêtes : débouncer la zone de recherche
Les champs de recherche sont une source classique de requêtes inutiles. Un gestionnaire naïf appelle l’API à chaque modification :
const handleSearch = (value) => {
fetchUsers(value);
};
En tapant le mot « React » dans ce champ, une requête est envoyée à chaque touche tapée :
R
Re
Rea
Reac
React
Cinq allers-retours pour une seule recherche, dont quatre renvoient des résultats que personne ne consultera. Une approche plus efficace consiste à attendre que l’utilisateur fasse une pause brève avant d’envoyer la requête. C’est ce que fait le débouncing : chaque nouvelle appel réinitialise un chronomètre, et la fonction encapsulée s’exécute uniquement lorsque le chronomètre expire sans interruption.
const handleSearch = debounce((value) => {
fetchUsers(value);
}, 300);
Avec une fenêtre de 300 ms, les utilisateurs qui tapent rapidement ne génèrent qu’une seule requête à la fin. Cela permet d’économiser le trafic réseau, la charge du serveur ainsi que le traitement côté client des réponses rejetées. Notez que rien ici ne concerne l’affichage ; les gains proviennent uniquement du fait de ne pas effectuer ces opérations inutiles.
Un point important à garder à l’esprit lorsqu’on utilise un tel outil d’aide à l’intérieur d’un composant : si debounce(...) est appelé directement dans le corps du composant, une nouvelle fonction déboussolée (ainsi qu’un nouveau timer) est créée à chaque rendu, ce qui annule l’effet du déboussolage. Il convient de la créer une seule fois, par exemple à l’aide de useMemo ou useRef, ou d’utiliser le schéma basé sur les effets présenté ci-dessous.
Un composant de recherche déboussolé autonome
La même idée peut être mise en œuvre uniquement avec les primitives de React, sans aucune bibliothèque d’aide. Commencez par les imports et deux états : le texte d’entrée actuel ainsi que la liste des utilisateurs récupérés.
import { useEffect, useState } from "react";
function UserSearch() {
const [search, setSearch] = useState("");
const [users, setUsers] = useState([]);
Un effet s’exécute chaque fois que search change. Au lieu de récupérer les données immédiatement, il planifie cette opération avec setTimeout. À l’intérieur du callback, une requête vide ou ne contenant que des espaces efface les résultats et termine prématurément sans effectuer de demande.
useEffect(() => {
const timer = setTimeout(async () => {
if (!search.trim()) {
setUsers([]);
return;
}
Pour une requête réelle, la fonction de rappel demande les utilisateurs correspondants, en encodant le terme de recherche afin que les caractères spéciaux ne perturbent pas l’URL :
const response = await fetch(
`/api/users?search=${encodeURIComponent(search)}`
);
Elle analyse ensuite le JSON et stocke le résultat, tout cela restant dans le délai de 300 ms :
const data = await response.json();
setUsers(data);
}, 300);
C’est dans la fonction de nettoyage que s’effectue réellement le débouncing. React l’exécute avant que l’effet ne soit exécuté à nouveau, de sorte que chaque frappe annule le délai en attente précédent :
return () => clearTimeout(timer);
}, [search]);
Finalement, le composant affiche un champ de saisie contrôlé lié à search :
return (
<div>
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search users..."
/>
Et il liste les utilisateurs, identifiés par leurs IDs :
{users.map((user) => (
<div key={user.id}>{user.name}</div>
))}
</div>
);
}
Chaque caractère saisi réinitialise le compteur temporel, et la requête n’est envoyée qu’après 300 ms de silence. Gardez à l’esprit que le débouncing réduit le nombre de requêtes envoyées, mais ne garantit pas qu’elles soient traitées dans l’ordre ; une réponse arrivant plus tôt mais plus lentement peut toujours écraser une réponse plus récente. Si cela est important pour votre interface utilisateur, combinez cette méthode avec l’annulation des requêtes, comme expliqué dans comment résoudre les conditions de concurrence que le débouncing ne peut pas résoudre dans les interfaces de recherche.
Leçon générale : prévenir le traitement inutile est généralement plus efficace que de simplement accélérer le traitement existant.
Récupérez uniquement les données nécessaires à l’écran
Un autre facteur de consommation fréquent est le téléchargement d’une quantité de données bien supérieure à celle affichée. Supposons qu’un point d’extrémité renvoie 10 000 utilisateurs tandis que l’affichage ne montre que 20 à la fois. Le navigateur doit néanmoins télécharger, parser et stocker tous ces utilisateurs en mémoire, et React doit traiter des tableaux bien plus volumineux que nécessaire.
Lorsque le cas d’usage le permet, utilisez la pagination afin que chaque requête ne transporte qu’une seule page :
API
↓
20 users
↓
Browser
↓
Display
Chaque fois que l’utilisateur avance, demandez la tranche suivante. Cela réduit l’utilisation du réseau, la consommation de mémoire, le traitement côté client ainsi que le volume de données circulant dans vos composants. Le défilement infini et les API basées sur le curseur suivent le même principe. Modifier la manière dont les données sont récupérées a souvent un effet bien plus important que de simplement ajuster un composant isolé.
Gardez les états en évolution rapide près de leurs consommateurs
Le lieu où se trouve l’état détermine quelle partie de l’arborescence est réaffichée lorsqu’il change. Prenons un tableau de bord qui gère le texte de recherche :
function Dashboard() {
const [search, setSearch] = useState("");
return (
<>
<SearchBox value={search} onChange={setSearch} />
<Analytics />
<UserTable />
</>
);
}
Chaque frappe met à jour Dashboard, ce qui entraîne également une réaffichage de Analytics et UserTable, même si aucun d’eux n’utilise la valeur de recherche. Cela a des conséquences importantes sur un tableau de bord volumineux. Si l’état n’est pertinent que pour une petite partie de l’interface, le placer dans cette partie (ici, directement dans SearchBox) limite les mises à jour aux endroits nécessaires et rend la structure des composants plus facile à comprendre.
Ce n’est pas une règle imposant systématiquement de déplacer l’état vers le niveau inférieur. Lorsque plusieurs composants ont réellement besoin de la même valeur, leur parent commun le plus proche est le propriétaire approprié. L’objectif est de conserver l’état au niveau le plus bas qui soit encore partagé par tous les composants qui le consultent réellement.
Donner des clés stables aux éléments de liste dynamiques
Les listes sont des endroits où de petits détails ont des effets disproportionnés. Un schéma courant consiste à utiliser l’index de l’array comme clé :
{users.map((user, index) => (
<UserCard key={index} user={user} />
))}
Les clés basées sur des indices ne sont pas toujours erronées. Pour une liste statique dont l’ordre ne change jamais, elles fonctionnent bien. Pour une liste dynamique, un identifiant stable provenant des données confère à chaque élément une identité fiable :
{users.map((user) => (
<UserCard key={user.id} user={user} />
))}
La différence réside dans ce que représente la clé :
index → position
id → identity
Si des éléments peuvent être ajoutés, supprimés, réordonnés ou filtrés, les positions changent et React associe des éléments incorrects : il peut rérender plus que nécessaire, et pire encore, il peut rattacher l’état interne d’un élément à celui d’un autre. Cela devient un bug visible lorsque les éléments de la liste contiennent des champs d’entrée, un état local ou d’autres éléments interactifs, comme un champ de texte qui conserve sa valeur saisie tandis que la ligne en dessous change. Préférez toujours un identifiant stable lorsque la liste peut changer.
Mettre en doute le traitement, pas seulement sa vitesse
Les transformations lourdes du côté client constituent une autre source de coûts évitables. Ici, les utilisateurs sont filtrés, triés et mappés vers de nouveaux objets :
const filteredUsers = users
.filter((user) => user.isActive)
.sort((a, b) => a.name.localeCompare(b.name))
.map((user) => ({
...user,
displayName: user.name.toUpperCase()
}));
Avec quelques milliers d’enregistrements, répéter cette opération à chaque rendu devient coûteux. La mise en mémoire tampon est une option, mais il faut d’abord se demander si le client a vraiment besoin de réaliser ce travail. Souvent, l’endpoint peut lui-même filtrer les utilisateurs actifs, la base de données peut gérer le tri grâce à des index qui rendent cette opération peu coûteuse, et la pagination peut réduire l’ensemble des données de manière à ce que le traitement restant soit trivial. Réduire les entrées est généralement plus efficace que d’accélérer le calcul.
Charger les fonctionnalités sur demande
Les applications volumineuses incluent des fonctionnalités que de nombreux utilisateurs n’ouvrent jamais au cours d’une session donnée. Une page de rapports, par exemple, peut être complètement distincte du tableau de bord principal. Plutôt que de la inclure dans le téléchargement initial, chargez-la de manière différée :
const Reports = lazy(() => import("./Reports"));
Avec React.lazy, le module est chargé la première fois que le composant est rendu, ce qui doit se produire à l’intérieur d’une balise Suspense affichant un contenu de remplacement pendant le chargement. Cela est particulièrement avantageux dans les applications ayant de nombreuses routes, de grandes sections fonctionnelles, des dépendances lourdes telles que des bibliothèques de graphiques ou d’édition, ou encore des écrans peu fréquentés par les utilisateurs.
Soyez clair sur ce que cela vous apporte : un volume initial de JavaScript plus réduit et un chargement plus rapide. Cela ne fait pas fonctionner le code à l’intérieur du composant différé plus rapidement une fois chargé, et cela ajoute un bref délai de chargement la première fois que la fonctionnalité est utilisée.
Mémoriser avec une raison
Une erreur courante consiste à utiliser des API d’optimisation de manière systématique dans tout le code, comme en enveloppant chaque gestionnaire d’événement :
const handleClick = useCallback(() => {
setSelectedUser(id);
}, [id]);
OU chaque valeur dérivée :
const data = useMemo(() => {
return processData(users);
}, [users]);
Ces hooks ont des utilisations légitimes, mais chacun ajoute du code, des tableaux de dépendances à maintenir corrects, ainsi qu’un petit coût en temps d’exécution. Avant d’en ajouter un, vérifiez :
- Le calcul est-il vraiment coûteux ?
- S’exécute-t-il fréquemment ?
- Le résultat est-il réutilisé lors de différentes rendus ?
- Un enfant mémorisé ou un effet dépend-il d’une référence stable ?
- Le changement aura-t-il un impact mesurable ?
Si les réponses sont principalement « non », le code le plus simple est le meilleur. Les performances proviennent d’une optimisation adaptée au bon endroit, et non de la quantité de code d’optimisation. Le React Compiler, que vous avez adopté, automatise une grande partie de cette mémoisation, ce qui est une autre raison de ne pas l’écrire manuellement partout ; consultez ce que le React Compiler optimise et ce qu’il vous laisse à gérer.
Checklist d’évaluation
Vérifiez ces points lors de l’audit d’une fonctionnalité React :
- Demandes inutiles ? Cherchez les appels par touche, les demandes redondantes, ainsi que les requêtes pour des données actuellement non affichées.
- Trop de données ? Utilisez la pagination, filtrez sur le serveur, et reportez les requêtes jusqu’au moment où les données sont nécessaires.
useMemo, useCallback ou React.memo simplement parce qu’ils existent.Les performances sont un processus continu, pas seulement un render
Les performances de React ne dépendent pas uniquement de React lui-même. Les coûts s’accumulent tout au long de la chaîne de traitement :
API calls
↓
Amount of data
↓
State updates
↓
Component structure
↓
Data processing
↓
Rendering
↓
Bundle size
Se concentrer uniquement sur la couche de rendu peut masquer un problème bien plus grave en amont. Réduire de quelques millisecondes le temps de rendu ne sert à peu de chose si la page continue d’envoyer des requêtes redondantes, et mémoriser un composant ne aide pas lorsque l’on télécharge des milliers d’enregistrements que l’on n’affiche jamais. Commencez par le sommet de la chaîne et travaillez votre chemin vers le bas.
Points clés
- Éliminer les tâches (requêtes, octets, mises à jour, code) donne généralement de meilleurs résultats que d’accélérer ces tâches.
- Débouchez les requêtes déclenchées par l’utilisateur, et associez ce débouçage à une annulation lorsque l’ordre des opérations est important.
- Laissez le serveur filtrer, trier et paginer ; n’envoyez au client que ce qu’il doit afficher.
- Placez l’état au niveau le plus bas qui serve tous ses lecteurs, et identifiez les listes dynamiques par leur identité.
- Utilisez le chargement différé pour un premier chargement plus léger, et n’optez pour la mémorisation que lorsque des coûts concrets le justifient.
Lectures complémentaires
- Ce que React Compiler optimise et ce qu’il laisse sur votre table — Comprenez quelles tâches d’amélioration des performances React Compiler automatise, pourquoi les API lentes et les fichiers volumineux restent à votre charge, et comment l’intégrer en toute sécurité dans une base de code React existante.
- Diagnostiquer les problèmes de performances de React au-delà du délai de réponse de l’API — Apprenez pourquoi des API rapides ne garantissent pas nécessairement une interface utilisateur rapide, et comment le rendu, la taille des fichiers et l’organisation des fichiers influencent discrètement les véritables performances d’une application React.
- Garder la page réactive : quand mettre le travail du CPU dans un web worker — Découvrez pourquoi les boucles lourdes figent l’interface utilisateur du navigateur, comment le modèle de messages des web workers permet de décharger ce travail, et comment déterminer si une tâche mérite réellement un worker.
- Budgets de performances pour JavaScript : imposer des limites dans le bundle en CI — Apprenez pourquoi JavaScript coûte bien plus que son temps de téléchargement, comment définir un budget de performances réaliste, et comment faire en sorte que webpack échoue les builds qui le dépassent.
- JavaScript lisible avec des exemples : dix refacturations avant et après — Dix petites refacturations en JavaScript et React, allant de la nomenclature et des objets de paramètres à la gestion des erreurs et au formatage, qui facilitent la lecture et la modification du code par le développeur suivant.
- Pourquoi React.lazy() a besoin d’un export par défaut et comment charger celles avec nom — Comprendre ce que React.lazy() reçoit d’une import dynamique, pourquoi les exports nommés le perturbent, comment les réassigner au par défaut, et comment Suspense et le splitage de code s’y intégrent.
- Concevoir des frontends offline-premier-choix en tant que repliques : files d’attente, redémarrages, conflits — Découvrez pourquoi l’approche offline-premier-choix transforme le navigateur en une réplique de données, et comment les stockages locaux, les files d’attente de mutations durables, les redémarrages idempotents et les règles de conflit s’intègrent entre eux.