Retour au blog

Les erreurs de performance que j'ai faites sur ma première vraie app

Des re-renders partout, des bundles de 2.5MB, 5000 noeuds DOM. Voici les 7 erreurs de performance que j'ai faites et les fixes qui ont vraiment marché.

11 février 202614 min de lecture
ReactPerformanceReact.memouseMemoLazy Loading
Les erreurs de performance que j'ai faites sur ma première vraie app
La Cascade de Re-renders - Avant et Après React.memo
La Cascade de Re-renders - Avant et Après React.memo

Tu sais ce qui fait plus mal qu'un bug en prod ? Une app qui marche... mais que personne veut utiliser parce qu'elle est trop lente.

C'est exactement ce qui m'est arrivé sur mon premier vrai projet.

Le code avait l'air correct. Ça marchait en développement. Mais en production, avec de vraies données et de vrais utilisateurs, tout semblait pataud. Les utilisateurs se plaignaient.

Ça m'a pris des semaines pour comprendre ce qui se passait. Voici les erreurs que j'ai faites et ce que j'ai appris en les corrigeant.


Erreur 1 : des re-renders partout

Je ne comprenais pas comment les re-renders React fonctionnent.

Chaque fois qu'un composant parent se re-render, tous ses enfants se re-renderent aussi. Même si leurs props n'ont pas changé. J'avais un dashboard avec 50+ composants, et cliquer sur un seul bouton déclenchait des centaines de re-renders.

function Dashboard() {
  const [selectedTab, setSelectedTab] = useState('overview');

  return (
    <div>
      <Tabs selected={selectedTab} onChange={setSelectedTab} />
      <Header />           {/* Re-render à chaque changement d'onglet */}
      <Sidebar />          {/* Re-render à chaque changement d'onglet */}
      <Chart data={data} /> {/* Re-render à chaque changement d'onglet */}
      <Table data={data} /> {/* Re-render à chaque changement d'onglet */}
      <Footer />           {/* Re-render à chaque changement d'onglet */}
    </div>
  );
}

Chaque clic sur un onglet re-renderait tout. Le composant Chart était coûteux. La Table avait 500 lignes. Mes utilisateurs sentaient chaque re-render.

Le fix : React.memo

const Chart = memo(function Chart({ data }) {
  // Ne re-render que si data change
  return <ExpensiveChart data={data} />;
});

const Table = memo(function Table({ data }) {
  // Ne re-render que si data change
  return <ExpensiveTable data={data} />;
});

Maintenant cliquer sur les onglets ne re-render pas Chart ou Table parce que leur prop data ne change pas.

Mais il y a un piège. Je l'ai appris à mes dépens aussi.


Erreur 2 : casser memo avec de nouvelles références d'objets

J'ai wrappé tout dans memo. Problème résolu, non ?

Non.

function Dashboard() {
  const [selectedTab, setSelectedTab] = useState('overview');

  // Nouvel objet créé à chaque render !
  const chartConfig = {
    showLegend: true,
    animate: false
  };

  return (
    <Chart data={data} config={chartConfig} /> {/* Re-render quand même ! */}
  );
}

memo fait une comparaison shallow. Chaque render crée un nouvel objet chartConfig. Nouvel objet = nouvelle référence = memo pense que les props ont changé = re-render.

Même problème avec les fonctions :

function Dashboard() {
  // Nouvelle fonction créée à chaque render !
  const handleClick = () => {
    console.log('clicked');
  };

  return <Button onClick={handleClick} />; {/* Re-render quand même ! */}
}

Le fix : useMemo et useCallback

function Dashboard() {
  const [selectedTab, setSelectedTab] = useState('overview');

  // Même référence entre les renders
  const chartConfig = useMemo(() => ({
    showLegend: true,
    animate: false
  }), []);

  // Même référence entre les renders
  const handleClick = useCallback(() => {
    console.log('clicked');
  }, []);

  return (
    <>
      <Chart data={data} config={chartConfig} />
      <Button onClick={handleClick} />
    </>
  );
}

Maintenant memo marche vraiment parce que les références restent stables.


Erreur 3 : tout importer d'entrée

Mon bundle faisait 2.5MB.

Chaque utilisateur téléchargeait 2.5MB de JavaScript avant de voir quoi que ce soit. Le panneau admin ? Téléchargé. La page de paramètres ? Téléchargée. La fonctionnalité d'export rarement utilisée ? Téléchargée.

import { AdminPanel } from './AdminPanel';
import { Settings } from './Settings';
import { ExportFeature } from './ExportFeature';
import { Dashboard } from './Dashboard';

function App() {
  return (
    <Routes>
      <Route path="/" element={<Dashboard />} />
      <Route path="/admin" element={<AdminPanel />} />
      <Route path="/settings" element={<Settings />} />
      <Route path="/export" element={<ExportFeature />} />
    </Routes>
  );
}

Le fix : lazy loading

import { lazy, Suspense } from 'react';

const Dashboard = lazy(() => import('./Dashboard'));
const AdminPanel = lazy(() => import('./AdminPanel'));
const Settings = lazy(() => import('./Settings'));
const ExportFeature = lazy(() => import('./ExportFeature'));

function App() {
  return (
    <Suspense fallback={<Spinner />}>
      <Routes>
        <Route path="/" element={<Dashboard />} />
        <Route path="/admin" element={<AdminPanel />} />
        <Route path="/settings" element={<Settings />} />
        <Route path="/export" element={<ExportFeature />} />
      </Routes>
    </Suspense>
  );
}

Maintenant les utilisateurs ne téléchargent que ce dont ils ont besoin. Le Dashboard charge en premier. Le panneau admin charge quand ils y naviguent.

Mon bundle initial est passé de 2.5MB à 400KB. Le first paint est passé de 4 secondes à moins d'1 seconde.


Erreur 4 : rendre des listes énormes

J'avais une table avec 5000 lignes. Je renderais les 5000.

function UserTable({ users }) {
  return (
    <table>
      <tbody>
        {users.map(user => (
          <tr key={user.id}>
            <td>{user.name}</td>
            <td>{user.email}</td>
            <td>{user.role}</td>
          </tr>
        ))}
      </tbody>
    </table>
  );
}

5000 noeuds DOM. Le scroll était un diaporama.

Le fix : virtualisation

import { FixedSizeList } from 'react-window';

function UserTable({ users }) {
  const Row = ({ index, style }) => (
    <div style={style}>
      {users[index].name} - {users[index].email}
    </div>
  );

  return (
    <FixedSizeList
      height={500}
      itemCount={users.length}
      itemSize={50}
      width="100%"
    >
      {Row}
    </FixedSizeList>
  );
}

Maintenant seules les lignes visibles sont dans le DOM. 5000 items ? Toujours seulement ~20 noeuds DOM à la fois. Le scroll est fluide comme du beurre.

Librairies qui font ça : react-window, react-virtualized, @tanstack/react-virtual.


Erreur 5 : ne pas utiliser le React DevTools Profiler

Je devinais où étaient les problèmes de performance. Parfois je devinais juste. La plupart du temps non.

Puis j'ai découvert le React DevTools Profiler.

Il te montre :

  • Quels composants ont rendu
  • Combien de temps chaque render a pris
  • Ce qui a déclenché le render
  • Les renders "gaspillés" (renders qui n'ont produit aucun changement visible)

Comment l'utiliser

  1. Installe l'extension React DevTools
  2. Ouvre DevTools → onglet Profiler
  3. Clique sur "Record"
  4. Fais l'action lente dans ton app
  5. Clique sur "Stop"
  6. Vois exactement quels composants sont lents

Le Profiler m'a montré que mon composant Table prenait 800ms à render. Pas parce qu'il était complexe, mais parce qu'il re-renderait à chaque frappe dans un input de recherche.

Je n'aurais jamais trouvé ça en lisant le code.


Erreur 6 : calculs coûteux dans le render

Je filtrais et triais les données dans le render :

function UserList({ users, searchTerm, sortBy }) {
  // S'exécute à CHAQUE render
  const filteredUsers = users
    .filter(u => u.name.includes(searchTerm))
    .sort((a, b) => a[sortBy].localeCompare(b[sortBy]));

  return (
    <ul>
      {filteredUsers.map(user => <li key={user.id}>{user.name}</li>)}
    </ul>
  );
}

Avec 5000 utilisateurs, filtrer et trier à chaque render tuait les performances.

Le fix : useMemo

function UserList({ users, searchTerm, sortBy }) {
  const filteredUsers = useMemo(() => {
    return users
      .filter(u => u.name.includes(searchTerm))
      .sort((a, b) => a[sortBy].localeCompare(b[sortBy]));
  }, [users, searchTerm, sortBy]);

  return (
    <ul>
      {filteredUsers.map(user => <li key={user.id}>{user.name}</li>)}
    </ul>
  );
}

Maintenant le calcul ne s'exécute que quand users, searchTerm, ou sortBy changent vraiment. Pas à chaque re-render du parent.


Erreur 7 : utiliser index comme key

Celle-là m'a bien mordu.

{items.map((item, index) => (
  <Item key={index} data={item} />
))}

Ça a l'air correct. Ça marche bien... jusqu'à ce que tu ajoutes, supprimes, ou réordonnes des items.

React utilise les keys pour tracker quels items ont changé. Si tu utilises index comme key :

  • Supprime item 0 → React pense que item 0 est devenu item 1, item 1 est devenu item 2...
  • Chaque item re-render avec les mauvaises données
  • Le state se mélange entre les items

Le fix : utiliser des IDs uniques

{items.map(item => (
  <Item key={item.id} data={item} />
))}

Utilise toujours un identifiant stable et unique. Si tes données n'ont pas d'IDs, crée-les quand les données sont créées, pas pendant le render.


Le modèle mental que j'aurais aimé avoir


La cheat sheet

ProblèmeSymptômeFix
Re-renders inutilesCliquer cause du lagReact.memo
Nouvelles refs objet/fonctionmemo n'aide pasuseMemo / useCallback
Gros bundleChargement initial lentReact.lazy + code splitting
Longues listesLag au scrollreact-window virtualisation
Calculs coûteuxLag sur interactionsuseMemo
Mauvaises keysItems de liste mélangésUtiliser des IDs uniques, pas index

La leçon

La performance c'est pas de la magie. C'est mesure + compréhension.

J'ai passé des semaines à deviner. J'aurais pu passer des heures à mesurer.

Le React DevTools Profiler m'aurait montré exactement où étaient les problèmes. La doc m'aurait appris memo, useMemo, et le lazy loading. Mais je pensais pouvoir comprendre tout seul.

La méthode difficile m'a appris : mesure d'abord, optimise ensuite, devine jamais.


C'est la partie 2 de ma série "Ce que j'ai appris à mes dépens". Prochain article : comment je structure un projet Next.js après 3 ans.

Continuer vers la partie 3 : Comment je structure un projet Next.js après 3 ans →


Des questions ? Contacte-moi sur LinkedIn ou découvre plus sur mon blog.