Retour au cours

frontend / react

Performance : memo et virtualisation

Leçon 131 exercice

Explication

Ce que vous allez apprendre

  • Savoir quand React devient réellement lent et pourquoi ce n'est pas systématique
  • Empêcher un composant de se re-rendre inutilement avec memo()
  • Comprendre pourquoi memo() échoue silencieusement sans props stables
  • Virtualiser une longue liste pour ne monter dans le DOM que ce qui est visible
  • Utiliser le Profiler React pour mesurer avant d'optimiser

Dans quel contexte ?

L'écran src/pages/AdminUsersPage.jsx affiche un tableau de 8000 utilisateurs et devient perceptiblement saccadé au scroll et à chaque frappe dans la barre de recherche du header, pourtant totalement indépendante du tableau. Le Profiler React révèle deux causes distinctes : chaque ligne se re-rend inutilement à cause d'une fonction recréée à chaque rendu, et les 8000 lignes sont montées dans le DOM même si seules 15 sont visibles à l'écran. Cette leçon règle les deux problèmes avec memo()/useCallback et la virtualisation.

Étape 1 : React est déjà rapide par défaut

Tant qu'une application reste petite, React est déjà rapide par défaut : la comparaison entre deux versions de l'interface est très optimisée.

Étape 2 : où les problèmes apparaissent

Les problèmes de performance apparaissent surtout à l'échelle : de grands tableaux de données, des composants qui se re-rendent en cascade sans raison visible.

Étape 3 : ce que fait vraiment memo()

memo() enveloppe un composant pour lui donner un réflexe simple : avant de le re-rendre, comparer ses nouvelles props aux précédentes ; si elles sont identiques, sauter complètement son rendu.

Étape 4 : la limite à bien comprendre

Cet outil ne fonctionne que si les props reçues restent réellement stables d'un rendu à l'autre, ce qui implique souvent de le combiner avec useMemo/useCallback côté parent.

Étape 5 : un autre problème, les longues listes

Pour une liste de plusieurs milliers d'éléments, même sans problème de re-rendu, monter tous ces éléments dans le DOM devient coûteux, car la majorité n'est jamais visible à l'écran.

Étape 6 : la solution, la virtualisation

La virtualisation consiste à ne monter réellement dans le DOM que les quelques éléments visibles à un instant donné, en simulant la hauteur totale avec un élément fantôme.

Piège fréquent

Envelopper un composant dans memo() sans stabiliser les props qu'il reçoit (fonctions recréées, objets littéraux) ne change strictement rien : les props sont "différentes" à chaque rendu, donc memo() ne peut jamais sauter le rendu.

Symptôme observéCause probableSolution
Toutes les lignes d'un tableau se re-rendent au moindre changementProps non stables (fonction recréée)memo() + useCallback
Scroll saccadé sur une liste de milliers d'itemsTous les items sont montés dans le DOMVirtualisation

La règle d'or à retenir

Aucune de ces techniques ne doit être appliquée par réflexe. La bonne méthode consiste toujours à mesurer d'abord, avec le Profiler de React.

Et ensuite ?

Une fois la performance de rendu comprise, la prochaine étape aborde un autre levier : ne charger le code JavaScript qu'au moment où il devient nécessaire, avec le code splitting.

Commandes & code

Performance : memo et virtualisation

Éviter de recalculer/re-rendre ce qui n'a pas changé, et ne jamais monter ce qui n'est pas visible.

jsx
import { memo } from "react";

// memo() : comparaison superficielle des props -- si identiques, saute le rendu du composant
const LigneUtilisateur = memo(function LigneUtilisateur({ utilisateur }) {
    console.log("Rendu de", utilisateur.nom);
    return <tr><td>{utilisateur.nom}</td><td>{utilisateur.email}</td></tr>;
});

// Comparateur custom : nécessaire si la comparaison superficielle par défaut ne suffit pas
const LigneOptimisee = memo(
    function LigneOptimisee({ utilisateur }) {
        return <tr><td>{utilisateur.nom}</td></tr>;
    },
    (prevProps, nextProps) => prevProps.utilisateur.id === nextProps.utilisateur.id
    // retourne true si on veut SKIP le rendu (props "égales" selon cette logique)
);
jsx
// memo() est inutile, voire contre-productif, si les props changent à chaque rendu de toute façon
function TableauUtilisateurs({ utilisateurs }) {
    return (
        <table>
            <tbody>
                {utilisateurs.map((u) => (
                    // si "onClick={() => faireChose(u.id)}" est recréé ici à chaque rendu du parent,
                    // LigneUtilisateur ne bénéficie JAMAIS de son memo() -- combiner avec useCallback
                    <LigneUtilisateur key={u.id} utilisateur={u} />
                ))}
            </tbody>
        </table>
    );
}
jsx
// Virtualisation : ne monter dans le DOM que les lignes VISIBLES, pas les 10 000 items
import { useState, useRef, useMemo } from "react";

function ListeVirtualisee({ items, hauteurLigne = 40, hauteurConteneur = 400 }) {
    const [scrollTop, setScrollTop] = useState(0);

    const indexDebut = Math.floor(scrollTop / hauteurLigne);
    const nbVisibles = Math.ceil(hauteurConteneur / hauteurLigne) + 2; // +2 : marge de sécurité (overscan)
    const indexFin = Math.min(items.length, indexDebut + nbVisibles);

    const itemsVisibles = useMemo(
        () => items.slice(indexDebut, indexFin),
        [items, indexDebut, indexFin]
    );

    return (
        <div
            style={{ height: hauteurConteneur, overflowY: "auto", position: "relative" }}
            onScroll={(e) => setScrollTop(e.target.scrollTop)}
        >
            {/* div fantôme : donne la hauteur TOTALE au scroll, comme si tout était rendu */}
            <div style={{ height: items.length * hauteurLigne, position: "relative" }}>
                {itemsVisibles.map((item, i) => (
                    <div
                        key={item.id}
                        style={{
                            position: "absolute",
                            top: (indexDebut + i) * hauteurLigne,
                            height: hauteurLigne,
                            width: "100%",
                        }}
                    >
                        {item.nom}
                    </div>
                ))}
            </div>
        </div>
    );
}
// en production : préférer une librairie mature (react-window, @tanstack/react-virtual)
// qui gère overscan dynamique, tailles variables, et scroll horizontal
jsx
// Profiler : mesurer AVANT d'optimiser, jamais l'inverse
import { Profiler } from "react";

function surRendu(id, phase, dureeReelle) {
    console.log(`${id} (${phase}) : ${dureeReelle.toFixed(2)}ms`);
}

function App() {
    return (
        <Profiler id="TableauUtilisateurs" onRender={surRendu}>
            <TableauUtilisateurs utilisateurs={utilisateurs} />
        </Profiler>
    );
}

Résumé

  • memo() compare les props superficiellement : n'a d'effet que si elles restent référentiellement stables.
  • Combiner memo() avec useCallback/useMemo côté parent, sinon les props "changent" à chaque rendu.
  • La virtualisation ne monte dans le DOM que les lignes visibles, indispensable au-delà de quelques centaines d'items.
  • Toujours profiler avant d'optimiser : la mémoïsation prématurée ajoute de la complexité pour un gain parfois nul.

Exercices pratiques

1 disponible
1

Mission : désaturer le tableau de 8000 utilisateurs

Objectif : Corriger un memo() inefficace faute de props stables, et virtualiser une longue liste pour éliminer le scroll saccadé.

Contexte

Le Profiler React confirme sur AdminUsersPage.jsx que taper dans la barre de recherche du header, totalement indépendante du tableau, re-rend quand même les 8000 LigneUtilisateur enveloppées dans memo(). Le composant parent recrée onClick={() => ouvrirDetail(u.id)} à chaque rendu directement dans le .map(). De plus, les 8000 <tr> sont toutes montées dans le DOM alors que seules 15 lignes sont visibles à l'écran, ce qui rend le scroll saccadé.

Résoudre l’exercice →