Skip to main content
frontend21 décembre 20258 min de lecture

5 erreurs de performance qui tuent votre application React

La plupart des applications React sont plus lentes qu'elles ne devraient l'être. Voici les 5 erreurs de performance les plus courantes que je vois dans les codebases de production — et comment les corriger.

reactperformancenextjs
5 erreurs de performance qui tuent votre application React

J'ai examiné des centaines de codebases React au fil des ans. Des projets clients, des dépôts open-source, des outils internes, des MVP de startups. Les mêmes problèmes de performance apparaissent encore et encore. Pas des cas exotiques — des erreurs basiques et évitables qui s'accumulent jusqu'à ce que l'application soit lente et que les utilisateurs partent.

Voici les cinq que je vois le plus souvent, avec des correctifs concrets que vous pouvez appliquer dès aujourd'hui.

1. Re-rendus inutiles par manque de mémoïsation

C'est le problème de performance le plus courant dans les applications React. Un composant parent se re-rend, et chaque enfant se re-rend avec lui — même si leurs props n'ont pas changé. Multipliez cela sur un arbre de composants profond avec des dizaines d'éléments dans une liste, et vous obtenez une application qui saccade à chaque frappe de clavier.

Le problème :

function Dashboard({ user }) {
  const [searchQuery, setSearchQuery] = useState("");

  const stats = calculateStats(user.transactions);

  return (
    <div>
      <SearchBar value={searchQuery} onChange={setSearchQuery} />
      <StatsPanel stats={stats} />
      <TransactionList transactions={user.transactions} />
    </div>
  );
}

Chaque fois que searchQuery change, calculateStats s'exécute à nouveau — même si user.transactions n'a pas changé. StatsPanel et TransactionList se re-rendent aussi sans raison.

La correction :

function Dashboard({ user }) {
  const [searchQuery, setSearchQuery] = useState("");

  const stats = useMemo(
    () => calculateStats(user.transactions),
    [user.transactions]
  );

  const handleSearchChange = useCallback(
    (value: string) => setSearchQuery(value),
    []
  );

  return (
    <div>
      <SearchBar value={searchQuery} onChange={handleSearchChange} />
      <MemoizedStatsPanel stats={stats} />
      <MemoizedTransactionList transactions={user.transactions} />
    </div>
  );
}

const MemoizedStatsPanel = memo(StatsPanel);
const MemoizedTransactionList = memo(TransactionList);

Trois choses se sont produites ici :

  1. useMemo empêche calculateStats de s'exécuter à chaque rendu. Il ne recalcule que quand user.transactions change réellement.
  2. useCallback stabilise la référence au gestionnaire de changement pour que SearchBar ne voie pas une "nouvelle" fonction à chaque rendu.
  3. memo encapsule les composants enfants pour qu'ils sautent le re-rendu quand leurs props sont superficiellement égales.

Un mot de prudence : n'encapsulez pas chaque composant dans memo aveuglément. Profilez d'abord en utilisant React DevTools Profiler. La mémoïsation a un coût — la comparaison superficielle elle-même. Appliquez-la là où c'est important : calculs coûteux, grandes listes, et composants qui se re-rendent fréquemment avec des props inchangées.

2. Bundles gonflés par absence de code splitting

Je vois régulièrement des applications React qui envoient tout dans un seul bundle JavaScript. Le tableau de bord admin, la page de paramètres, le centre d'aide rarement visité — tout chargé d'avance à la première visite. L'utilisateur voulait voir une landing page, et son navigateur vient de télécharger 2 Mo de JavaScript dont il n'a pas encore besoin.

Le problème :

import AdminDashboard from "./pages/AdminDashboard";
import Settings from "./pages/Settings";
import HelpCenter from "./pages/HelpCenter";
import Landing from "./pages/Landing";

function App() {
  return (
    <Routes>
      <Route path="/" element={<Landing />} />
      <Route path="/admin" element={<AdminDashboard />} />
      <Route path="/settings" element={<Settings />} />
      <Route path="/help" element={<HelpCenter />} />
    </Routes>
  );
}

Les imports statiques font que le bundler inclut tout dans un seul chunk. Quelle que soit la route visitée par l'utilisateur, il télécharge tout.

La correction :

import { lazy, Suspense } from "react";

const AdminDashboard = lazy(() => import("./pages/AdminDashboard"));
const Settings = lazy(() => import("./pages/Settings"));
const HelpCenter = lazy(() => import("./pages/HelpCenter"));
import Landing from "./pages/Landing"; // Keep critical path static

function App() {
  return (
    <Suspense fallback={<PageSkeleton />}>
      <Routes>
        <Route path="/" element={<Landing />} />
        <Route path="/admin" element={<AdminDashboard />} />
        <Route path="/settings" element={<Settings />} />
        <Route path="/help" element={<HelpCenter />} />
      </Routes>
    </Suspense>
  );
}

React.lazy avec un import() dynamique dit au bundler de séparer ces pages en chunks distincts. Ils sont chargés à la demande quand l'utilisateur navigue vers cette route. Votre bundle initial reste petit, et votre Time to Interactive diminue significativement.

Si vous utilisez Next.js, vous obtenez le code splitting par route gratuitement avec l'App Router. Mais vous devez quand même être intentionnel sur le code splitting au niveau des composants pour les bibliothèques tierces lourdes :

import dynamic from "next/dynamic";

const RichTextEditor = dynamic(() => import("@/components/RichTextEditor"), {
  loading: () => <EditorSkeleton />,
  ssr: false,
});

C'est critique pour les bibliothèques comme les éditeurs de texte enrichi, les bibliothèques de graphiques, ou les renderers PDF qui peuvent facilement ajouter 200-500 Ko à votre bundle.

3. État placé trop haut dans l'arbre de composants

Le modèle de rendu de React est descendant. Quand l'état d'un composant change, il se re-rend — et tout son sous-arbre aussi. Quand vous remontez l'état au sommet de l'arbre "par commodité", vous demandez à React de re-rendre tout en dessous à chaque changement d'état.

Le problème :

function App() {
  const [selectedFilter, setSelectedFilter] = useState("all");
  const [isModalOpen, setIsModalOpen] = useState(false);
  const [searchQuery, setSearchQuery] = useState("");

  return (
    <Layout>
      <Header />
      <Sidebar filter={selectedFilter} onFilterChange={setSelectedFilter} />
      <MainContent>
        <SearchBar query={searchQuery} onChange={setSearchQuery} />
        <ProductList filter={selectedFilter} />
        <Modal isOpen={isModalOpen} onClose={() => setIsModalOpen(false)} />
      </MainContent>
      <Footer />
    </Layout>
  );
}

Taper dans la barre de recherche déclenche une mise à jour d'état dans App. Cela re-rend Header, Sidebar, Footer, ProductList, et Modal — dont aucun ne se soucie de la requête de recherche.

La correction :

Poussez l'état vers le bas, là où il est réellement utilisé :

function App() {
  return (
    <Layout>
      <Header />
      <SidebarWithFilter />
      <MainContent>
        <SearchSection />
        <ProductList />
      </MainContent>
      <Footer />
    </Layout>
  );
}

function SearchSection() {
  const [searchQuery, setSearchQuery] = useState("");

  return (
    <div>
      <SearchBar query={searchQuery} onChange={setSearchQuery} />
      <SearchResults query={searchQuery} />
    </div>
  );
}

Maintenant, taper dans la barre de recherche ne re-rend que SearchSection et ses enfants. Tout le reste reste intact.

Le principe est simple : l'état doit vivre aussi près que possible des composants qui le lisent. Si deux composants frères ont besoin du même état, remontez-le à leur plus proche parent commun — mais pas plus haut. Pour l'état véritablement global (auth, thème, locale), utilisez une bibliothèque de gestion d'état qui supporte les souscriptions sélectives, comme Zustand ou Jotai, pour que seuls les composants consommant une tranche spécifique de l'état se re-rendent quand cette tranche change.

4. Images non optimisées dans Next.js

Les images sont généralement les ressources les plus lourdes sur une page web. J'ai vu des applications Next.js où les développeurs utilisent des balises <img> simples avec des PNG massifs et non compressés. Le résultat : une page qui charge 8 Mo d'images, zéro lazy loading, pas de dimensionnement responsive, et un temps de Largest Contentful Paint qui fait pleurer Lighthouse.

Le problème :

function ProjectCard({ project }) {
  return (
    <div className="card">
      <img src={`/images/${project.cover}`} alt={project.title} />
      <h3>{project.title}</h3>
      <p>{project.description}</p>
    </div>
  );
}

Cela sert l'image en pleine résolution à sa taille originale, quelle que soit la fenêtre d'affichage ou l'appareil de l'utilisateur. Pas de conversion de format, pas de lazy loading, pas de srcset responsive.

La correction :

import Image from "next/image";

function ProjectCard({ project }) {
  return (
    <div className="card">
      <Image
        src={`/images/${project.cover}`}
        alt={project.title}
        width={600}
        height={400}
        sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
        placeholder="blur"
        blurDataURL={project.blurHash}
      />
      <h3>{project.title}</h3>
      <p>{project.description}</p>
    </div>
  );
}

Le composant Image de Next.js vous donne automatiquement plusieurs choses :

  • Conversion automatique de format vers WebP ou AVIF selon le support du navigateur
  • Dimensionnement responsive via l'attribut sizes — le navigateur ne télécharge que la taille dont il a besoin
  • Lazy loading par défaut — les images sous le pli ne sont pas récupérées tant que l'utilisateur ne scrolle pas à proximité
  • Placeholder flou — affiche un aperçu léger pendant le chargement de l'image complète, éliminant le décalage de mise en page

Pour les images au-dessus du pli (bannières hero, contenu principal), ajoutez priority pour désactiver le lazy loading et les précharger :

<Image src={heroImage} alt="Hero" priority sizes="100vw" />

Ce seul changement peut réduire le poids de la page de 60 à 80 % et améliorer considérablement vos scores Core Web Vitals.

5. Bloquer le thread principal avec des calculs lourds

Le thread principal du navigateur gère le rendu, les entrées utilisateur, les animations et l'exécution JavaScript — le tout sur un seul thread. Quand vous exécutez un calcul lourd de manière synchrone, tout le reste se fige. L'utilisateur clique sur un bouton et rien ne se passe pendant 500ms. Les animations saccadent. Le scroll ne répond plus.

Le problème :

function AnalyticsDashboard({ rawData }) {
  const [processedData, setProcessedData] = useState(null);

  useEffect(() => {
    // This blocks the main thread for 200-800ms
    const result = processLargeDataset(rawData); // Sorting, filtering, aggregating 50k+ rows
    setProcessedData(result);
  }, [rawData]);

  if (!processedData) return <Spinner />;
  return <Charts data={processedData} />;
}

processLargeDataset s'exécute de manière synchrone sur le thread principal. Si cela prend 400ms, l'ensemble de l'interface est figé pendant 400ms. L'utilisateur ne peut pas scroller, taper ou interagir avec quoi que ce soit.

La correction — déplacez-le dans un Web Worker :

// workers/dataProcessor.ts
self.onmessage = (event: MessageEvent) => {
  const rawData = event.data;
  const result = processLargeDataset(rawData);
  self.postMessage(result);
};
function AnalyticsDashboard({ rawData }) {
  const [processedData, setProcessedData] = useState(null);
  const workerRef = useRef<Worker | null>(null);

  useEffect(() => {
    workerRef.current = new Worker(
      new URL("../workers/dataProcessor.ts", import.meta.url)
    );

    workerRef.current.onmessage = (event: MessageEvent) => {
      setProcessedData(event.data);
    };

    workerRef.current.postMessage(rawData);

    return () => {
      workerRef.current?.terminate();
    };
  }, [rawData]);

  if (!processedData) return <Spinner />;
  return <Charts data={processedData} />;
}

Le calcul lourd s'exécute maintenant sur un thread séparé. Le thread principal reste libre de gérer les interactions utilisateur, les animations et le rendu. L'interface reste réactive tout du long.

Pour les cas moins extrêmes où le calcul prend 50-150ms, vous pouvez découper le travail en morceaux plus petits en utilisant requestIdleCallback ou scheduler.yield() au lieu de lancer un Worker complet. Mais pour tout ce qui traite de grands jeux de données, génère des rapports ou exécute des algorithmes complexes, les Web Workers ne sont pas optionnels — ils sont nécessaires.

L'effet composé

Aucune de ces erreurs ne fera planter votre application à elle seule. C'est ce qui les rend dangereuses. Chacune ajoute 100-300ms de délai, et les utilisateurs subissent le total cumulé. Un composant qui se re-rend inutilement plus un bundle gonflé plus des images non optimisées plus un calcul sur le thread principal signifie que votre application met 4 secondes à devenir interactive au lieu de 1.

Profilez avant d'optimiser. Utilisez React DevTools Profiler pour trouver les points chauds de re-rendu. Utilisez Lighthouse et Web Vitals pour mesurer l'impact réel sur les utilisateurs. Utilisez l'onglet Network de votre navigateur pour repérer les bundles et images surdimensionnés. Utilisez l'onglet Performance pour identifier les tâches longues bloquant le thread principal.

Ce sont des patterns que j'applique dans chaque projet que je construis — des plateformes de restauration aux systèmes SaaS médicaux. La performance n'est pas une fonctionnalité qu'on ajoute plus tard. C'est une qualité qu'on maintient dès le premier commit.

DU

Danil Ulmashev

Full Stack Developer

Intéressé par une collaboration ?