Skip to main content
frontend21 dicembre 20258 min di lettura

5 Errori di Performance Che Uccidono la Tua App React

La maggior parte delle app React sono più lente del necessario. Ecco i 5 errori di performance più comuni che vedo nelle codebase in produzione — e come risolverli.

reactperformancenextjs
5 Errori di Performance Che Uccidono la Tua App React

Ho esaminato centinaia di codebase React nel corso degli anni. Progetti client, repository open-source, strumenti interni, MVP di startup. Gli stessi problemi di performance si ripresentano continuamente. Non si tratta di casi limite esotici — ma di errori basilari e prevenibili che si accumulano fino a quando l'app non risulta lenta e gli utenti iniziano ad abbandonarla.

Ecco i cinque che vedo più spesso, con soluzioni reali che puoi applicare oggi stesso.

1. Re-render Inutili Dovuti a Mancanza di Memoizzazione

Questo è il problema di performance più comune nelle app React. Un componente padre si ri-renderizza, e ogni figlio si ri-renderizza con esso — anche se le loro prop non sono cambiate. Moltiplica questo effetto su un albero di componenti profondo con decine di elementi in una lista, e avrai un'app che scatta ad ogni pressione di tasto.

Il problema:

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>
  );
}

Ogni volta che searchQuery cambia, calculateStats viene eseguito di nuovo — anche se user.transactions non è cambiato. Anche StatsPanel e TransactionList si ri-renderizzano senza motivo.

La soluzione:

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);

Qui sono successe tre cose:

  1. useMemo impedisce a calculateStats di essere eseguito ad ogni render. Ricalcola solo quando user.transactions cambia effettivamente.
  2. useCallback stabilizza il riferimento all'handler di cambiamento in modo che SearchBar non veda una funzione "nuova" ad ogni render.
  3. memo avvolge i componenti figli in modo che saltino il ri-rendering quando le loro prop sono superficialmente uguali.

Un avvertimento: non avvolgere ogni componente in memo ciecamente. Effettua prima una profilazione usando React DevTools Profiler. La memoizzazione ha un costo — il confronto superficiale stesso. Applicala dove è importante: calcoli costosi, liste grandi e componenti che si ri-renderizzano frequentemente con prop invariate.

2. Bundle Gonfi Dovuti a Mancanza di Code Splitting

Vedo regolarmente app React che spediscono tutto in un unico bundle JavaScript. La dashboard di amministrazione, la pagina delle impostazioni, il centro assistenza raramente visitato — tutto caricato in anticipo alla prima visita della pagina. L'utente voleva vedere una landing page, e il suo browser ha appena scaricato 2 MB di JavaScript di cui non ha ancora bisogno.

Il problema:

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>
  );
}

Gli import statici significano che il bundler include tutto in un unico chunk. Indipendentemente dalla rotta che l'utente visita, scarica tutto.

La soluzione:

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 con import() dinamico indica al bundler di dividere queste pagine in chunk separati. Vengono caricati su richiesta quando l'utente naviga verso quella rotta. Il tuo bundle iniziale rimane piccolo e il tuo Time to Interactive diminuisce significativamente.

Se stai usando Next.js, ottieni il code splitting basato sulle rotte gratuitamente con l'App Router. Ma devi comunque essere intenzionale riguardo allo splitting a livello di componente per le librerie di terze parti pesanti:

import dynamic from "next/dynamic";

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

Questo è fondamentale per librerie come editor di testo ricchi, librerie di grafici o renderer PDF che possono facilmente aggiungere 200-500 KB al tuo bundle.

3. Stato Posizionato Troppo in Alto nell'Albero dei Componenti

Il modello di rendering di React è top-down. Quando lo stato di un componente cambia, esso si ri-renderizza — e così fa l'intero suo sottoalbero. Quando sollevi lo stato in cima all'albero "per comodità", stai dicendo a React di ri-renderizzare tutto ciò che si trova sotto di esso ad ogni cambiamento di stato.

Il problema:

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>
  );
}

Digitare nella barra di ricerca attiva un aggiornamento di stato in App. Questo ri-renderizza Header, Sidebar, Footer, ProductList e Modal — nessuno dei quali si preoccupa della query di ricerca.

La soluzione:

Sposta lo stato verso il basso, dove viene effettivamente utilizzato:

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>
  );
}

Ora digitare nella barra di ricerca ri-renderizza solo SearchSection e i suoi figli. Tutto il resto rimane intatto.

Il principio è semplice: lo stato dovrebbe risiedere il più vicino possibile ai componenti che lo leggono. Se due componenti fratelli necessitano dello stesso stato, sollevalo al loro genitore comune più vicino — ma non più in alto. Per uno stato veramente globale (autenticazione, tema, locale), usa una libreria di gestione dello stato che supporti sottoscrizioni selettive, come Zustand o Jotai, in modo che solo i componenti che consumano una specifica porzione di stato si ri-renderizzino quando quella porzione cambia.

4. Immagini Non Ottimizzate in Next.js

Le immagini sono tipicamente gli asset più pesanti su una pagina web. Ho visto app Next.js dove gli sviluppatori usano semplici tag <img> con PNG massicci e non compressi. Il risultato: una pagina che carica 8 MB di immagini, zero lazy loading, nessuna dimensione responsiva e un tempo di Largest Contentful Paint che fa piangere Lighthouse.

Il problema:

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

Questo serve l'immagine a piena risoluzione nella sua dimensione originale, indipendentemente dal viewport o dal dispositivo dell'utente. Nessuna conversione di formato, nessun lazy loading, nessun srcset responsivo.

La soluzione:

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>
  );
}

Il componente Image di Next.js ti offre automaticamente diverse cose:

  • Conversione automatica del formato in WebP o AVIF in base al supporto del browser
  • Dimensionamento responsivo tramite l'attributo sizes — il browser scarica solo la dimensione di cui ha bisogno
  • Lazy loading per impostazione predefinita — le immagini al di sotto della piega non vengono caricate finché l'utente non scorre vicino ad esse
  • Segnaposto sfocato — mostra un'anteprima leggera mentre l'immagine completa si carica, eliminando lo spostamento del layout

Per le immagini sopra la piega (banner hero, contenuto primario), aggiungi priority per disabilitare il lazy loading e precaricarle:

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

Questa singola modifica può ridurre il peso della pagina del 60-80% e migliorare drasticamente i tuoi punteggi Core Web Vitals.

5. Blocco del Thread Principale con Calcoli Pesanti

Il thread principale del browser gestisce il rendering, l'input dell'utente, le animazioni e l'esecuzione di JavaScript — tutto su un singolo thread. Quando esegui un calcolo pesante in modo sincrono, tutto il resto si blocca. L'utente clicca un pulsante e non succede nulla per 500ms. Le animazioni scattano. Lo scroll smette di rispondere.

Il problema:

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 viene eseguito in modo sincrono sul thread principale. Se impiega 400ms, l'intera UI è bloccata per 400ms. L'utente non può scorrere, digitare o interagire con nulla.

La soluzione — spostalo in 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} />;
}

Il calcolo pesante ora viene eseguito su un thread separato. Il thread principale rimane libero di gestire le interazioni dell'utente, le animazioni e il rendering. L'UI rimane reattiva per tutto il tempo.

Per casi meno estremi in cui il calcolo richiede 50-150ms, puoi suddividere il lavoro in chunk più piccoli usando requestIdleCallback o scheduler.yield() invece di avviare un Worker completo. Ma per qualsiasi cosa che elabori grandi dataset, generi report o esegua algoritmi complessi, i Web Worker non sono opzionali — sono necessari.

L'Effetto Composto

Nessuno di questi errori farà crashare la tua app da solo. Questo è ciò che li rende pericolosi. Ognuno aggiunge 100-300ms di ritardo, e gli utenti sperimentano la somma totale. Un componente che si ri-renderizza inutilmente più un bundle gonfio più immagini non ottimizzate più un calcolo sul thread principale significa che la tua app impiega 4 secondi per diventare interattiva invece di 1.

Profila prima di ottimizzare. Usa React DevTools Profiler per trovare i punti caldi di ri-rendering. Usa Lighthouse e Web Vitals per misurare l'impatto reale sull'utente. Usa la scheda Rete del tuo browser per individuare bundle e immagini sovradimensionati. Usa la scheda Performance per identificare le attività lunghe che bloccano il thread principale.

Questi sono schemi che applico in ogni progetto che realizzo — dalle piattaforme per ristoranti ai sistemi SaaS medici. La performance non è una funzionalità che aggiungi in seguito. È una qualità che mantieni dal primo commit.

DU

Danil Ulmashev

Full Stack Developer

Interessato a collaborare?