Ottimizzare le prestazioni di React: memo, useMemo, useCallback e quando non usarli

Ottimizzare le prestazioni di React: memo, useMemo, useCallback e quando non usarli

A dicembre 2024 ho ereditato il frontend di un portale gestionale costruito con React 18 per un cliente del settore servizi professionali - un'applicazione con circa 60 componenti, 14 pagine e una dashboard che mostrava grafici, tabelle e form complessi. L'applicazione funzionava correttamente, ma la navigazione tra le pagine aveva un ritardo percepibile (300-500 ms di "freeze" a ogni cambio schermata), l'input nei form di ricerca mostrava lag visibile di 200 ms tra la digitazione e l'aggiornamento della lista filtrata, e la dashboard impiegava 2,3 secondi per renderizzare tutti i widget dopo un aggiornamento dei dati. Lo sviluppatore precedente, conscio dei problemi di performance, aveva applicato la sua soluzione: avvolgere praticamente tutto in React.memo(), useMemo() e useCallback(). Il codebase conteneva 214 chiamate a questi hook di memoizzazione. La sua teoria era che "più memoizzazione = meno re-render = più velocità." Il profiling con React DevTools Profiler ha dimostrato che la teoria era sbagliata: l'80% delle memoizzazioni non produceva alcun beneficio misurabile, il 15% era attivamente dannoso (aggiungeva overhead di confronto senza prevenire re-render), e solo il 5% - circa 10 utilizzi su 214 - stava effettivamente ottimizzando qualcosa.

In due giorni di lavoro ho rimosso 180 delle 214 memoizzazioni, ristrutturato la composizione dei componenti per prevenire i re-render alla fonte, e aggiunto 3 nuove memoizzazioni mirate dove il profiling indicava un beneficio reale. Il risultato: la navigazione tra pagine è passata da 300-500 ms a meno di 100 ms, l'input nei form filtro è diventato istantaneo, e la dashboard renderizza in 800 ms - il 65% in meno. Il codice è più leggibile, più semplice, e paradossalmente più veloce con il 92% in meno di memoizzazione. Questo articolo spiega perché succede e come identificare i casi in cui memo, useMemo e useCallback servono davvero.

Perché la memoizzazione indiscriminata rallenta React invece di velocizzarlo?

La risposta è nel costo nascosto di ogni memoizzazione. Quando avvolgi un componente in React.memo(), React deve fare un confronto shallow delle props ad ogni render del componente padre - confrontare ogni proprietà dell'oggetto props per verificare se qualcosa è cambiato. Se le props non sono cambiate, React salta il re-render del componente (beneficio). Ma se le props sono cambiate (cosa che succede nella maggioranza dei casi quando il padre si re-renderizza con dati nuovi), React ha fatto il confronto e poi ha fatto il re-render - pagando il costo del confronto senza ottenere nessun beneficio. Il confronto shallow ha un costo proporzionale al numero di props: un componente con 15 props richiede 15 confronti ad ogni render del padre. Se il componente si re-renderizza comunque nel 95% dei casi perché riceve dati freschi, quei 15 confronti sono overhead puro.

Lo stesso vale per useMemo() e useCallback(). Ogni useMemo() conserva in memoria il valore calcolato e l'array di dipendenze della chiamata precedente, e ad ogni render confronta le dipendenze attuali con quelle precedenti per decidere se ricalcolare. Se le dipendenze cambiano frequentemente - tipicamente perché includono un oggetto o un array creato nel render - il useMemo non previene mai il ricalcolo ma aggiunge sempre il costo del confronto delle dipendenze e il costo della memoria per conservare il valore precedente. Un useMemo(() => items.filter(i => i.active), [items]) dove items è un nuovo array ad ogni render (perché viene da useSelector o da una chiamata API) non memoizza nulla: il confronto items !== prevItems è sempre true perché gli array sono oggetti diversi anche se contengono gli stessi dati.

La documentazione ufficiale di React è esplicita su questo punto: "useMemo is a React Hook that lets you cache the result of a calculation between re-renders" - between re-renders, non instead of re-renders. Se il componente si ri-renderizza comunque perché il padre si ri-renderizza, il useMemo rallenta (overhead di confronto) invece di velocizzare. Nel mio profilo professionale trovi il dettaglio dell'esperienza multi-stack che porto in queste ottimizzazioni - la competenza su React non è il mio stack primario, ma l'approccio "profila prima, ottimizza dopo" è identico a quello che applico sulle applicazioni PHP, e funziona esattamente allo stesso modo.

Come identificare i re-render che valgono la pena di essere ottimizzati

Prima di aggiungere qualsiasi memoizzazione, devi profilare l'applicazione con React DevTools Profiler per capire dove i re-render stanno causando problemi visibili all'utente. Non tutti i re-render sono uguali: un componente che si re-renderizza in 0,3 ms non ha bisogno di ottimizzazione anche se si re-renderizza 50 volte al secondo. Un componente che si re-renderizza in 150 ms e blocca il thread principale per quel tempo ha bisogno di ottimizzazione anche se si re-renderizza una volta sola.

Il processo che seguo è in tre passi. Primo, abilito il Profiler di React DevTools e registro una sessione dell'interazione problematica (la digitazione nel filtro, la navigazione tra pagine, l'aggiornamento della dashboard). Secondo, ordino i componenti per tempo di commit (il tempo che React ha impiegato per renderizzarli) e identifico quelli sopra i 16 ms - la soglia per mantenere 60fps. Terzo, per ogni componente lento, analizzo perché si sta ri-renderizzando: il padre si è ri-renderizzato? Le props sono cambiate? Lo state interno è cambiato? Il contesto è cambiato?

Nel caso del portale gestionale, il Profiler ha rivelato che il componente più lento era DataTable - una tabella con 500 righe e 8 colonne che si ri-renderizzava completamente ogni volta che l'utente digitava nel campo di filtro sopra la tabella. Il motivo: il campo di filtro e la tabella erano nello stesso componente padre, e il useState del filtro causava un re-render del padre e di conseguenza della tabella. La soluzione non era memoizzare la tabella - era separare il campo di filtro dalla tabella in due componenti fratelli con un contesto condiviso, in modo che il re-render del filtro non coinvolgesse la tabella. Questa ristrutturazione della composizione ha risolto il problema di performance senza una sola riga di memoizzazione.

Quando memo, useMemo e useCallback servono davvero: i tre casi legittimi

Dopo aver rimosso il 92% delle memoizzazioni e ristrutturato la composizione dei componenti, ho aggiunto 3 nuove memoizzazioni mirate - ciascuna giustificata da un profiling che dimostrava un beneficio misurabile.

Caso 1: React.memo() su un componente puro con render costoso. Il componente ChartWidget renderizzava un grafico SVG con 2.000 punti dati e impiegava 80 ms per render. Il componente padre (la dashboard) si ri-renderizzava ogni 15 secondi per il polling dei dati, ma nella maggior parte dei casi i dati del grafico non cambiavano. Avvolgere ChartWidget in React.memo() con un comparatore custom che verificava solo il campo data delle props (ignorando i callback) ha eliminato l'80% dei re-render del grafico - un risparmio di 80 ms × 4 re-render al minuto = 320 ms al minuto di thread principale liberato.

Caso 2: useMemo() su un calcolo computazionalmente costoso. Il componente di reportistica calcolava aggregazioni su 10.000 record - somme, medie, raggruppamenti per categoria - e il calcolo impiegava 45 ms. Il useMemo con dipendenza sull'array di record evitava il ricalcolo quando l'utente cambiava tab nella dashboard senza modificare il dataset. Senza il useMemo, il calcolo veniva rieseguito ad ogni re-render della dashboard (4 volte al minuto) anche quando i dati non erano cambiati.

Caso 3: useCallback() per stabilizzare una prop passata a un componente memoizzato. Il ChartWidget memoizzato con React.memo() riceveva una prop onPointClick che era una callback definita nel padre. Senza useCallback, la callback era un nuovo riferimento funzione ad ogni render del padre, il che invalidava il React.memo del grafico e rendeva la memoizzazione inutile. Il useCallback stabilizzava il riferimento della callback tra i render del padre, permettendo al React.memo del grafico di funzionare correttamente.

Questi tre casi illustrano la regola pratica: React.memo() serve su componenti con render costoso (>16 ms) che si ri-renderizzano frequentemente con le stesse props. useMemo() serve per calcoli che costano più di qualche millisecondo e le cui dipendenze non cambiano ad ogni render. useCallback() serve solo per stabilizzare callback passate a componenti memoizzati - da solo non ha alcun beneficio. In tutti gli altri casi - il 95% del codice React tipico - la memoizzazione aggiunge complessità e overhead senza beneficio misurabile.

La soluzione strutturale: composizione dei componenti, non memoizzazione

La lezione più importante che ho tratto dal refactoring del portale gestionale è che la vera ottimizzazione delle performance React non sta nella memoizzazione - sta nella composizione dei componenti. Il 70% dei problemi di performance che ho risolto non richiedeva alcun hook: richiedeva di spostare lo state nel componente più vicino a dove viene usato (state colocation), di separare i componenti che cambiano frequentemente da quelli che cambiano raramente (splitting), e di passare i componenti come children invece che come risultato di una funzione (children pattern).

Il children pattern è il più sottovalutato. Se un componente pesante (la tabella) è dentro un componente che ha uno state che cambia frequentemente (il filtro), il re-render del filtro causa il re-render della tabella. Ma se la tabella viene passata come children al componente filtro, React non la ri-renderizza quando lo state del filtro cambia - perché il riferimento JSX della tabella è lo stesso tra i render. Questa ottimizzazione è gratuita, non richiede memo, non aggiunge complessità, e funziona in ogni caso. È il tipo di soluzione che preferisco: zero overhead, zero magia, zero rischio di introdurre bug di stale closure.

Un altro pattern strutturale che ha prodotto risultati significativi è lo state lifting inverso - portare lo state giù nella gerarchia dei componenti invece che su. Nel portale gestionale, un componente DashboardPage conteneva 8 useState per gli 8 widget della dashboard (dati, stato di caricamento, errori, filtri per ciascun widget). Ogni volta che uno dei 16 stati cambiava (8 dati + 8 loading), l'intera pagina si ri-renderizzava - inclusi tutti e 8 i widget, anche quelli i cui dati non erano cambiati. La soluzione è stata estrarre ogni widget in un componente autonomo con il proprio useState e il proprio useEffect per il fetching dei dati. Il componente DashboardPage è diventato un layout container senza stato: riceve solo i parametri di configurazione (quali widget mostrare, in quale ordine) e renderizza i componenti widget come children. Ogni widget gestisce il proprio ciclo di vita indipendentemente - quando i dati del widget "Fatturato mensile" si aggiornano, solo quel widget si ri-renderizza, senza coinvolgere i 7 widget fratelli. Questa ristrutturazione da sola ha ridotto il numero medio di componenti coinvolti in un re-render della dashboard da 47 a 6, con un impatto diretto sui 150 ms di freeze che l'utente percepiva durante il polling dei dati.

La regola che ne derivo è: lo stato di un componente dovrebbe vivere nel punto più basso della gerarchia che ha bisogno di leggerlo. Se solo un widget usa quei dati, lo stato deve essere nel widget. Se due widget fratelli condividono i dati, lo stato deve essere nel padre comune più prossimo. Se lo stato è nel componente radice "per comodità" o "per avere tutto in un posto," stai pagando re-render inutili su tutta la gerarchia - e nessuna quantità di React.memo può compensare un problema di architettura dei componenti.

Ho applicato lo stesso principio "profila prima, risolvi il problema alla radice" nel mio lavoro di ottimizzazione di applicazioni PHP su VPS Hetzner, dove il profiling con Blackfire identifica i veri colli di bottiglia prima di qualsiasi intervento - il parallelo è perfetto: come useMemo è spesso usato come cerotto su un problema di architettura dei componenti, così l'upgrade hardware è spesso usato come cerotto su un problema di query o di caching nel backend. In entrambi i casi, la soluzione giusta è strutturale, non palliativa. Se hai un'applicazione React con problemi di performance e il tuo team ha risposto aggiungendo memoizzazione ovunque, contattami per una sessione di profiling e ristrutturazione: in una giornata identifichiamo i veri colli di bottiglia con il Profiler, rimuoviamo le memoizzazioni inutili, e ristrutturiamo la composizione dei componenti per prestazioni migliori con meno codice.

Ultima modifica: