Ottimizzazione dei cicli di rendering di React | Aulab

GUIDE PER ASPIRANTI PROGRAMMATORI

Ottimizzazione dei cicli di rendering di React

Al di là delle domande elencate nella sezione precedente, React fornisce dei modi per ottimizzare i cicli di rendering. Ne vedremo quattro.   Lazy e i componenti Il primo modo per ottimizzare un’applicazione React ha a che fare con i componenti.  Essendo le applicazioni React delle SPA (che non sta per “Società per Azioni” ma…

Lezione 40 / 41
Enza Neri
Immagine di copertina

Vuoi avviare una nuova carriera o fare un upgrade?

Trova il corso Digital & Tech più adatto a te nel nostro catalogo!

Al di là delle domande elencate nella sezione precedente, React fornisce dei modi per ottimizzare i cicli di rendering. Ne vedremo quattro.

Lazy e i componenti

Il primo modo per ottimizzare un’applicazione React ha a che fare con i componenti

Essendo le applicazioni React delle SPA (che non sta per “Società per Azioni” ma per “Single Page Application”, o applicazioni a pagina singola), quando si naviga da una pagina all’altra non si sta caricando una nuova pagina web, ma si sta caricando un nuovo componente. Di conseguenza, le nostre applicazioni contengono sempre tutto il codice di tutti i componenti e il browser lo riceve ogni volta che viene visitata anche una sola pagina.

Man mano che un’applicazione diventa più grande e complessa, il numero dei componenti che contiene aumenta. Per evitare che React carichi tutti i componenti tutte le volte, anche quando un utente visita una sola pagina, possiamo usare la funzione lazy.

La funzione lazy funziona molto bene con il rendering condizionale, e istruisce React a caricare un componente solo nel momento in cui deve far parte di un ciclo di rendering.

Per esempio, immaginiamo di avere a disposizione una funzione hook, usePageNameFromUrl, che restituisce il nome della pagina che l’utente vuole visitare tramite l’URL del browser. Immaginiamo che il nome di questa pagina possa avere valori noti, come “home”, “categories” e “products”. Ad ogni pagina corrisponde un componente controller che, rappresentando un’intera pagina dell’applicazione, contiene una gran quantità di componenti figli:

import { usePageNameFromUrl } from "./hooks/usePageNameFromUrl";
import HomePage from "./pages/HomePage";
import CategoriesPage from "./pages/CategoriesPage";
import ProductsPage from "./pages/ProductsPage";

export default function App() {
  const pageName = usePageNameFromUrl();

  return (
    <div className="App">
      {(() => {
        switch (pageName) {
          case "home":
            return <HomePage />;
          case "categories":
            return <CategoriesPage />;
          case "products":
            return <ProductsPage />;
          default:
            return null;
        }
      })()}
    </div>
  );
}

In questo caso, i tre componenti HomePage, CategoriesPage e ProductsPage vengono sempre caricati in memoria da React. Se un utente visita solo una delle tre pagine, gli altri due componenti vengono comunque caricati.

Per evitare questa cosa, possiamo usare la funzione lazy in questo modo:

import { lazy, Suspense } from "react";
import { usePageNameFromUrl } from "./hooks/usePageNameFromUrl";

const HomePage = lazy(() => import("./pages/HomePage"));
const CategoriesPage = lazy(() => import("./pages/CategoriesPage"));
const ProductsPage = lazy(() => import("./pages/ProductsPage"));

export default function App() {
  const pageName = usePageNameFromUrl();

  return (
    <div className="App">
      <Suspense fallback="Loading…">
        {(() => {
          switch (pageName) {
            case "home":
              return <HomePage />;
            case "categories":
              return <CategoriesPage />;
            case "products":
              return <ProductsPage />;
          }
        })()}
      </Suspense>
    </div>
  );
}

Nota: lazy funziona solo se il componente è stato esportato usando export default. Ecco perché, quando abbiamo parlato di import/export all’inizio di questa guida, abbiamo introdotto la convenzione di esportare sempre i componenti usando default!

La funzione import non deve essere importata perché è parte del bundler di React (Create React App o Vite, per esempio). Il componente Suspense è un componente messo a disposizione da React, che accetta una proprietà fallback di tipo JSX (abbiamo usato una stringa “Loading…” nell’esempio, ma potrebbe essere un componente) che rappresenta ciò che sarà visualizzato durante il caricamento dei componenti (in questo caso HomePage, CategoriesPage e/o ProductsPage). Il componente Suspense deve essere genitore di tutti i componenti caricati tramite lazy, così che possa gestirli visualizzando il caricamento quando opportuno.

Attenzione! Utilizzare lazy per tutti i componenti non è una buona idea. Consideriamo che, se React dovesse caricare con lazy tutti i componenti dell’applicazione (in questo caso HomePage e CategoriesPage e ProductsPage), i tempi di attesa per l’utente non diminuirebbero, anzi aumenterebbero! React ci mette sicuramente meno a caricare tre componenti in un colpo solo che separatamente. Il miglioramento avviene quando non tutti i componenti devono essere caricati e sta nel fatto che i tempi di attesa sono separati nel tempo (poca attesa ogni volta che l’utente cambia pagina, invece di tanta attesa alla visita della prima pagina). Se usassimo lazy per tutti i componenti, anche quelli interni, raggiungeremmo un punto in cui l’attesa che deriva dal fatto che React deve caricare i componenti separatamente diventerebbe uguale o maggiore del tempo effettivamente usato per caricare i componenti, peggiorando la situazione.

Memo e i componenti

Il secondo modo per ottimizzare i cicli di rendering è la funzione memo.

Sappiamo che, ogni volta che lo stato di un componente viene aggiornato, tutti i suoi componenti figli vengono chiamati al rendering (i.e.: le funzioni che rappresentano i componenti vengono eseguite nuovamente).

In caso di applicazioni in cui la logica è concentrata in componenti che stanno molto in alto nell’albero, con tanti figli, l’applicazione potrebbe rallentare per via di un eccessivo numero di componenti che vengono chiamati al rendering a ogni cambiamento di stato. Lo stesso vale per applicazioni in cui si fa un eccessivo uso dei Context, che, tipicamente, contengono intere parti di applicazione, se non proprio l’intera applicazione.

La funzione memo fornita da React ci aiuta a risolvere questo problema. Si tratta di una HOF (higher order function) che istruisce React a chiamare al rendering un componente solo quando le sue props vengono aggiornate.

import { memo } from "react";

function MyComponent({ ...props }) {
  // Normale implementazione del componente qui
}

export default memo(MyComponent);

L’unica differenza con la creazione di un normale componente è che, invece di esportare il componente/funzione, lo passiamo come argomento alla funzione memo e restituiamo il risultato di memo.

UseMemo e le costanti

Il terzo modo per ottimizzare i cicli di rendering ha a che fare con le costanti. Non parleremo di variabili perché, come abbiamo visto, possiamo evitare di usarle per evitare mutazioni, che sono una forma di effetto collaterale, tramite IIFE/IIAE (immediately invoked function/arrow expressions).

Immaginiamo un caso in cui abbiamo dei dati che arrivano dalla rete. Per motivi su cui non abbiamo controllo, i dati sono tanti e in un formato molto diverso da quello che ci serve (immaginare di non avere il controllo su questa cosa è l’unico modo che abbiamo per non mostrare, come unica soluzione, quella di intervenire sul back-end).

export function MyComponent() {
  import { useQuery } from "../hooks/useQuery";

  const [myResponse] = useQuery({
    path: "/path/to/some/data",
  });

  const myData = myResponse.map((data) => {
    // Qui viene fatta una trasformazione di molti dati che richiede molto tempo
  });

  return (
    <div className="MyComponent">
      <Query
        query={myData}
        render={(data) => {
          /* rendering... */
        }}
      />
    </div>
  );
}

Nell’esempio soprastante, immaginiamo che il codice che restituisce myData richieda molto tempo, per esempio svariati secondi. Per via del funzionamento di React, non essendo myData uno stato, il codice “lento” viene eseguito ogni volta che viene scatenato un ciclo di rendering, cioè ogni volta che uno stato o una prop di MyComponent vengono aggiornati, se abbiamo usato memo, altrimenti anche ogni volta che uno stato di un parente qualsiasi di MyComponent viene aggiornato.

Visto che myData dipende solo dallo stato di myResponse, sarebbe molto utile poter chiedere a React di ricalcolare myData solo quando myResponse viene aggiornata, ignorando gli aggiornamenti di altri stati o prop che potrebbero essere coinvolti del rendering di MyComponent.

Possiamo ottenere questa cosa usando la funzione hook useMemo fornita da React:

import { useMemo } from "react";
import { useQuery } from "../hooks/useQuery";

export function MyComponent() {
  const [myResponse] = useQuery({
    path: "/path/to/some/data",
  });

  const myData = useMemo(() => {
    return myResponse.map((data) => {
      // Qui viene fatta una trasformazione di molti dati che richiede molto tempo
    });
  }, [myResponse]);

  return (
    <div className="MyComponent">
      <Query
        query={myData}
        render={(data) => {
          /* rendering... */
        }}
      />
    </div>
  );
}

useMemo ha una sintassi molto simile a useEffect, accettando una funzione di callback e una lista di dipendenze. Il suo ruolo è però fondamentalmente diverso da quello di useEffect:

  • useEffect, rappresentando un effetto collaterale, accetta solo funzioni spurie che non restituiscono nulla. useMemo rappresenta invece il calcolo di qualcosa, quindi accetta solo funzioni pure, che restituiscono qualcosa e utilizzano solo le proprie dipendenze e costanti dichiarate internamente alla funzione.
  • Le funzioni passate a useEffect vengono eseguite dopo i cicli di rendering, quelle passate a useMemo vengono eseguite durante i cicli di rendering.
  • useEffect rende i componenti spurii, useMemo non compromette la purezza di un componente.

Attenzione! Anche in questo caso, utilizzare useMemo per tutte le costanti non è una buona idea. React deve controllare quali dipendenze sono state aggiornate per ogni ciclo di rendering e ogni utilizzo di useMemo, e potrebbe metterci più tempo di quello che ci metterebbe a calcolare la costante e basta. L’utilizzo di useMemo è consigliabile solo per operazioni molto lente e solo quando non c’è modo di renderle meno lente.

Un altro caso d’uso di useMemo è quello in cui, per qualche motivo, ci troviamo a dover passare una costante a una funzione hook o come dipendenza di useEffect. Per evitare che la funzione hook o l’effetto collaterale vengano eseguiti a ogni ciclo di rendering, possimo inserire il calcolo della costante in una chiamata a useMemo. Questa situazione è di solito sintomo di problemi di progettazione, e possiamo fare un esempio proprio con la nostra applicazione di esempio. Lo faremo subito dopo con `useCallback“.

UseCallback e le funzioni

Il quarto e ultimo modo di ottimizzare i cicli di rendering è la funzione hook useCallback, fornita da React. Si tratta di una replica di useMemo, che possiamo utilizzare con le funzioni, come quelle che gestiscono gli eventi, invece che con le costanti.

Potresti chiederti: “perché mai dovrei voler controllare la quantità di volte in cui una funzione viene ricreata?” Dopotutto, è incredibilmente raro che la creazione di una funzione richieda tempi particolarmente lunghi. La chiamata alla funzione potrebbe, ma questa non c’entra niente con i cicli di rendering di React.

L’uso di useCallback che serve sempre per “giustificare” il passaggio di una funzione come dipendenza di useEffect, caso che coincide con l’ultimo caso d’uso di useMemo di cui abbiamo parlato, e di cui possiamo fare un esempio proprio con la nostra applicazione di esempio.

Quando abbiamo aggiunto la chiamata a useEffect alla funzione hook useQueryState, avevamo lasciato il commento FIXME:

// File: src/hooks/useQueryState.js

// ...

export function useQueryState({ path, initialData }) {
  // ...

  // FIXME:
  // eslint-disable-next-line react-hooks/exhaustive-deps
  const sendQuery = (data) => {
    setQueryState((state) => {
      return state.load();
    });

    sendNetworkRequest({ path, method: "GET", data }).then(
      (response) => {
        setQueryState((state) => {
          return state.succeed(response);
        });
      },
      (error) => {
        setQueryState((state) => {
          return state.fail(error);
        });
      }
    );
  };

  // ...

  useEffect(() => {
    if (queryState.isIdle()) {
      sendQuery(initialData);
    }
  }, [queryState, sendQuery, initialData]);

  // ...
}

Il commento FIXME coincide con il commento eslint-disable-next-line react-hooks/exhaustive-deps, che sta zittendo un avvertimento di React. Sappiamo che zittire gli avvertimenti di React non è mai una bella mossa, per cui capiamo di cosa React ci sta avvertendo.

Abbiamo aggiunto sendQuery come dipendenza della chiamata a useEffect perchè utilizziamo sendQuery nel contesto dell’effetto collaterale. La funzione sendQuery è, però, salvata in una costante che, quindi, verrà ricalcolata per ogni ciclo di rendering.

L’avvertimento di React è proprio questo: essendo sendQuery ricalcolata a ogni ciclo di rendering e passata come dipendenza a useEffect, l’effetto collaterale verrà eseguito per ogni ciclo di rendering. Non abbiamo quindi nessun vantaggio nell’usare un effetto collaterale, se non il fatto che useEffect fa in qualche modo da “marcatore” permettendoci di dichiarare apertamente che quello è un effetto collaterale. A parte l’effetto “marcatore”, tanto varrebbe eseguire il codice direttamente nel componente, rimuovendo l’uso di useEffect, il risultato sarebbe lo stesso.

useCallback e useMemo ci forniscono la possibilità di far riverberare il concetto di “ricalcola solo in base alle dipendenze” applicandolo a cose che non sono effetti collaterali, ma costanti e funzioni:

// File: src/hooks/useQueryState.js

export function useQueryState({ path, initialData }) {
  // ...

  const sendQuery = useCallback(
    (data) => {
      setQueryState((state) => {
        return state.load();
      });

      sendNetworkRequest({ path, method: "GET", data }).then(
        (response) => {
          setQueryState((state) => {
            return state.succeed(response);
          });
        },
        (error) => {
          setQueryState((state) => {
            return state.fail(error);
          });
        }
      );
    },
    [path]
  );

  // ...
}

Utilizzando useCallback, stiamo chiedendo a React di mantenere in memoria la funzione sendQuery a meno che path non venga aggiornata. Di conseguenza, anche l’effetto collaterale erediterà questo funzionamento (al di là di altre eventuali dipendenze che potrebbe avere).

Abbiamo usato useCallback perché sendQuery è una funzione, avremmo usato useMemo per una costante. Rispetto a tutte le costanti e funzioni a cui facciamo riferimento all’interno della funzione sendQuery, path è l’unica che non viene dall’esterno del componente (è una prop), di conseguenza è l’unica dipendenza che abbiamo.

Ci siamo liberati dell’avvertimento di React, ma non abbiamo risolto il problema! Prendiamo un caso d’uso qualsiasi di useQueryState:

// File: src/App.jsx

export default function App() {
  // ...

  const [todoListNetworkRequest, retry, setTodoList] = useQueryState({
    path: "/todo",
    initialData,
  });

  // ...
}

path è un valore costante, di conseguenza viene ricalcolata a ogni ciclo di rendering. Questo significa che, all’interno di useQueryState, sendQuery verrà ricalcolata a ogni ciclo di rendering, e la chiamata a useEffect pure. Se vogliamo raggiungere l’obiettivo di avere l’effetto collaterale all’interno di useQueryState che viene eseguito solo quando serve, abbiamo due possibilità:

  1. Utilizzare useMemo su path. path ha un valore costante, per cui possiamo calcolarlo senza dipendenze:
    // File: src/App.jsx
    
    export default function App() {
      // ...
    
      const path = useMemo(() => {
        return "/todo";
      }, []);
    
      const [todoListNetworkRequest, retry, setTodoList] = useQueryState({
        path: "/todo",
        initialData,
      });
    
      // ...
    }
  2. Spostare path al di fuori del componente, così che diventi indipendente dai cicli di rendering di React:

    // File: src/App.jsx
    
    const path = "/todo";
    
    export default function App() {
      // ...
    
      const [todoListNetworkRequest, retry, setTodoList] = useQueryState({
        path: "/todo",
        initialData,
      });
    
      // ...
    }

La soluzione 1. (useMemo) è più indicata per casi in cui path potrebbe contenere cose diverse a seconda di stati diversi, per esempio se contenesse il numero di pagina per un URL che richiede paginazione (qualcosa come /todo?page=2). In un caso del genere, l’URL cambierebbe a seconda di uno stato e quello stato dovrebbe essere una dipendenza di useMemo.

La soluzione 2. (costante al di fuori del componente) è più indicata per casi in cui si tratta di una costante sempre uguale. Il nostro è un esempio di questo caso, perché eventuali parametri farebbero parte di initialData e, successivamente, argomenti della funzione retry. Non ha senso, per noi, aggiungere parametri a path.

In ogni caso, abbiamo due problemi concettuali di cui non possiamo liberarci:

  1. Per poter usare useEffect, abbiamo generato una rete di dipendenze che si è allargata fino a fuori dal nostro componente. Ci è andata anche bene in questo caso, perché il cambiamento si è propagato per due soli passi (sendQuery e path), poteva andare molto peggio.
  2. Stiamo delegando all’esterno la responsabilità di ottimizzare la nostra funzione hook useQueryState: chi dovrà ricordarsi di usare useMemo o dichiarare path fuori dal componente sarà chi userà useQueryState. Anche volendo inserire questa regola nella documentazione, si tratta di una regola per niente intuitiva, slegata dal concetto di chiamata di rete (ha a che fare unicamente con la nostra implementazione tecnica) e stiamo andando contro la nostra regola di semplificare la vita al prossimo.

Sei indeciso sul percorso? 💭

Parliamone! Scrivici su Whatsapp e risponderemo a tutte le tue domande per capire quale dei nostri corsi è il più adatto alle tue esigenze.

Oppure chiamaci al 800 128 626