Foto Andrea Mangano
Andrea Mangano

Recupero dati remoti tramite gli hook di React

Tutorial
28/07/2019
15m
Il recupero dati è un problema ricorrente all'interno di applicazioni web. In questo tutorial mostro un approccio per gestire le chiamate, i dati e lo stato delle richieste utilizzando le funzionalità degli hook React. Il risultato dimostra quando sia pratico l'utilizzo e più semplice il riuso del codice.
React.js
Hooks
Recupero dati
Componenti
Aiutami a diffonderlo

Scopo del tutorial

In ogni applicazione (reale) occorre reperire o salvare dati remoti.

In questo tutorial mostro come:

  • Creare un componente funzionale che recupera e visualizza dati in lista, utilizzando gli hook state ed effect forniti da React.
  • Generalizzare le logiche di recupero dati all’interno di un hook personalizzato
  • Aggiungere alcuni piccoli miglioramenti per rendere l’hook creato maggiormente flessibile.

Ecco un’anteprima del risultato finale.

wrapper hell

Edit Data fetching with React hooks V2 (with antd comps)

Recupero dati classico con Class Component

Nell’approccio consueto, un componente di classe:

  • Recupera i dati all’interno del metodo componentDidMount
  • Memorizza i dati ricevuti all’interno del proprio state
  • Accede ai dati nello stato per renderizzare la vista

L’esempio base sottostante chiarisce quando appena detto:

class UserList extends React.Component {
  state = {
    users: []
  };

  async componentDidMount() {
    try {
      const data = await ClientHTTP("/api/my-users-endpoint");
      this.setState({ users: data });
    } catch (error) {
      console.log(error);
    }
  }

  render() {
    const { users } = this.state;

    return (
      <div>
        <h3>Lista utenti</h3>
        <ul>
          {users.map(user => (
            <li key={user.id}>{user.first_name}</li>
          ))}
        </ul>
      </div>
    );
  }
}

Cosa cambia adottando un approccio basato sugli hook? Come è possibile farlo?

Breve introduzione agli Hook React

Gli hook sono stati introdotti nell’Ottobre del 2018 per permettere, ai classici componenti funzionali, di dichiarare uno stato ed eseguire operazioni con side-effect (ad esempio funzioni di listener o, per l’appunto, di recupero dati).

Poichè ciò va contro la definizione stessa di “funzionale” si ricorre al termine “Function Component” per definire questa nuova categoria di componenti.

Lo scopo principale degli hook riguarda la massimizzazione di utilizzo del codice.

Concetti di astrazione e riusabilità furono già introdotti con i pattern HOC (Higher Order Component) e i componenti di Render Prop. Questi design pattern creano una sorta di “capsula” attorno al componente che li utilizza, di fatto estendendo le possibilità di accedere a dati o a funzionalità.

Purtroppo l’uso intensivo di questo approccio, causa quello che è stato definito “The Wrapper Hell”, ovvero l’incapsulamento multiplo di componenti al fine di sfruttare le funzionalità specifiche di ognuno.

wrapper hell

Con gli hook si ha la possibilità di:

  • Eliminare l’albero di componenti creato dagli HOC;
  • Sviluppare logiche e funzionalità incapsulandole in funzioni;
  • Utilizzare tali funzioni (anche in maniera composita) dai Function Component.

Detto questo, la documentazione di React toglierà, qualora vi fosse, qualsiasi dubbio su ragioni e utilizzo. Torniamo a noi!

Recupero dati tramite useEffect

Ho mostrato in precedenza, come sia possibile caricare dati da un Class Component una volta che il DOM è stato montato. La porzione di codice a seguire, mostra come ottenere il medesimo risultato attraverso l’uso degli hook.

import React, { useState, useEffect } from "react";

const UserList = () => {
  const [users, setUsers] = useState([]);

  useEffect(() => {
    const fetch = async () => {
      try {
        const data = await ClientHTTP("/api/my-users-endpoint");
        setUsers(data);
      } catch (error) {
        console.log(error);
      }
    };

    fetch();
  }, []);

  return (
    <div>
      <h3>Lista utenti</h3>
      <ul>
        {users.map(user => (
          <li key={user.id}>{user.first_name}</li>
        ))}
      </ul>
    </div>
  );
};

Il nostro componente è definito attraverso una funzione, dentro la quale troviamo la definizione dello state e la dichiarazione di useEffect (ed ovviamente il jsx della vista!).

In particolare:

  • useState: È utilizzato per aggiungere uno state a componenti funzionali;
  • useEffect: Ci permette di utilizzare replicare le funzionalità del lifecycle (componentDidMount, componentDidUpdate, componentUnmount) in componenti funzionali.

Nell’esempio, definisco l’attributo dell’oggetto state dove salvo i dati (nome users) e il metodo che mi permette di impostarli (setUsers).

// Dichiara l'attributo 'users' e il metodo per impostarlo (setState)
// assegnando un insieme vuoto come valore iniziale
const [users, setUsers] = useState([]);

L’hook dell’effetto (useEffect) viene invece utilizzato per recuperare i dati dall’API e per impostare i dati nello stato locale del componente.

Il corpo della funzione effect assegna alla costante load il metodo di recupero dati che è successivamente invocato.

  useEffect(() => {
    // Definisco la funzione di reperimento dati
    const load = async () => {
      try {
        const data = await ClientHTTP("/api/my-users-endpoint");
        setUsers(data);
      } catch (error) {
        console.log(error);
      }
    };

    // Invoco la funzione
    load();
  }, []);

Questo meccanismo potrebbe non essere immediatamente comprensibile.

Perchè definire il metodo di reperimento dati e invocarlo dentro la funzione di useEffect, piuttosto che passare il metodo stesso come parametro?

Potrei infatti pensare di semplificare come segue:

  useEffect(
    async () => {
      try {
        const data = await ClientHTTP("/api/my-users-endpoint");
        setUsers(data);
      } catch (error) {
        console.log(error);
      }
    };
  }, []);

Purtroppo l’uso di una funzione async direttamente dentro useEffect non è permesso. L’esecuzione scatena un warning perchè async ritorna implicitamente una Promise, contrariamente al valore atteso da React (nessun valore di ritorno o una funzione di cleanup).

Altre considerazioni vanno fatte sul secondo parametro di useEffect (nell’esempio impostato ad array vuoto).

Esso indica un array di variabili da cui dipende l’esecuzione dell’effect. In altre parole, quando cambia una variabile, useEffect viene nuovamente eseguito.

Di default, l’hook UseEffect è eseguito ogni volta che un componente viene aggiornato, ma spesso occorre recuperare i dati solo quando si monta inizialmente il componente nel DOM, esattamente come componentDidMount.

Per interrompere questo comportamento in useEffect passo al secondo argomento un array vuoto []. L’effetto sarà cosí invocato solo alla creazione del componente e mai durante l’aggiornamento.

Da notare che è necessario definire un array vuoto anche qualora non si desidera dipendere da nessuna variabile.

Se si omette questo parametro, useEffect scatenerà un loop. Poiché lo stato viene reimpostato dopo ogni recupero dati, il componente si aggiorna e l’effetto viene nuovamente eseguito.

Gestione degli stati di caricamento e di errore

Per migliorare la UX del componente, fornisco all’interfaccia uno stato di caricamento e un eventuale messaggio di errore. Per questo scopo aggiungo le variabili isLoading e hasError allo state del compontente.

  const [isLoading, setIsLoading] = useState(true);
  const [hasError, setHasError] = useState(false);

Imposto il valore iniziale di isLoading a true poichè il recupero dati inizierà non appena il componente è montato.

Per la variabile hasError è sufficiente un valore booleano, visto che maschero all’utente il problema reale, fornendogli un generico messaggio di errore.

Aggiorno quindi la funzione useEffect con gli indicatori di stato:

  useEffect(() => {
    const fetch = async () => {
      // Resetto eventuali errori prima di caricare altri dati
      setHasError(false);

      try {
        const data = await ClientHTTP("/api/my-users-endpoint");
        setUsers(data);
      } catch (error) {
        // Memorizzo il verificarsi dell'errore
        setHasError(true);
      }

      // Setto la variabile isLoading a false (caricamento finito)
      setIsLoading(false);
    };

    fetch();
  }, []);

Infine, utilizzo le variabili isLoading e hasError per renderizzare la vista.

    <div>
      <h3>Lista utenti</h3>
      {isLoading && <div>Loading...</div>}
      {!isLoading && hasError && (
        <div>Si è verificato un errore nel reperimento dati</div>
      )}
      {!isLoading && (
        <ul>
          {users.map(user => (
            <li key={user.id}>{user.first_name}</li>
          ))}
        </ul>
      )}
    </div>

Gestione della paginazione

Cosa cambia nel dover gestire un reperimento dati paginato?

Allo stato corrente, il componente UserList carica i dati degli utenti un’unica volta dopo essere stato montato in pagina.

Devo quindi, in maniera programmatica, innescare l’hook che mi recupera i dati ogni qualvolta l’utente clicca su un dato numero di pagina.

Procedo per gradi.

Prima cosa, ipotizzo che il mio servizio /api/my-users-endpoint?page=2 risponda nel seguente modo:

{
  "page": 2,
  "per_page": 3,
  "total": 12,
  "total_pages": 4,
  "data": [
    {
      "id": 4,
      "email": "eve.holt@reqres.in",
      "first_name": "Eve",
      "last_name": "Holt",
      "avatar": "eve.holt.jpg"
    },
    {
      "id": 5,
      "email": "charles.morris@reqres.in",
      "first_name": "Charles",
      "last_name": "Morris",
      "avatar": "charles.morris.jpg"
    },
    {
      "id": 6,
      "email": "tracey.ramos@reqres.in",
      "first_name": "Tracey",
      "last_name": "Ramos",
      "avatar": "tracey.ramos.jpg"
    }
  ]
}

Nel JSON mi vengono restituiti i dati degli utenti e, in aggiunta, i valori della pagina corrente, del numero totale di pagine (e record) e del numero massimo di utenti fornito in ogni pagina.

Questi dati sono necessari per costruire la paginazione attraverso la quale l’utente accederà a tutti i dati.

Aggiungo una variabile currentPage allo state e la imposto inizialmente ad 1. Essa terrà traccia della la pagina del listato correntemente visualizzata:

  const [currentPage, setCurrentPage] = useState(1);

Procedo quindi modificando la mia funzione di recupero dati:

  useEffect(() => {
    const fetch = async () => {
      setHasError(false);

      try {
        // Appendo alla richiesta il parametro che indica il numero di pagina
        const data = await ClientHTTP(`/api/my-users-endpoint?page=${currentPage}`);
        setResponse(data);
      } catch (error) {
        setHasError(true);
      }
      setIsLoading(false);
    };

    fetch();
  }, []);

Aggiorno l’url della richiesta apponendo il parametro page.

Per coerenza, modifico il nome della variabile di stato users in response (e di conseguenza setUsers in setResponse) poichè la risposta non conterrà solo il listato utenti ma le suddette info di paginazione.

Creo una vista elementare per la paginazione:

<div>
  {[...Array(response.total_pages).keys()].map(index => {
    const page = index + 1;
    return (
      <span onClick={onPageClick(page)}>
        {page === response.page ? <strong>{page}</strong> : page}{" "}
      </span>
    );
  })}
</div>

Ad ogni numero di pagina associo la funzione al click onPageClick cosí definita:

  function onPageClick(page) {
    return () => setCurrentPage(page);
  }

Tale funzione imposta il valore della currentPage al valore della pagina cliccata.

Provo il componente, clicco sulla seconda pagina, ma non succede nulla.

La currentPage nello state si è aggiornata, ma al click non s’innesca nessuna nuova richiesta.

Questo accade perché ho precedentemente passato un array vuoto come secondo argomento dell’effetto.

L’effetto pertanto non dipende da nessuna variabile, motivo per cui è attivato solo quando il componente renderizza la prima volta.

Per risolvere il problema, aggiungo il riferimento alla currentPage nell’array di dipendenze dell’effetto.

  useEffect(() => {
    ...
    // Aggiungo currentPage all'array di variabile da cui l'esecuzione dell'effetto dipende
  }, [currentPage]);

Riprovo. Cambio di pagina —> effetto invocato —> dati di pagina recuperati!

Ecco come dovrebbe apparire l’intero codice del componente:

const UserList = () => {
  const [response, setResponse] = useState([]);
  const [isLoading, setIsLoading] = useState(true);
  const [hasError, setHasError] = useState(false);

  const [currentPage, setCurrentPage] = useState(1);

  useEffect(() => {
    const fetch = async () => {
      setHasError(false);

      try {
        const data = await ClientHTTP(`/api/my-users-endpoint?page=${currentPage}`);
        setResponse(data);
      } catch (error) {
        setHasError(true);
      }
      setIsLoading(false);
    };

    fetch();
  }, [currentPage]);

  function onPageClick(page) {
    return () => setCurrentPage(page);
  }

  return (
    <div>
      <h3>Lista utenti</h3>
      {isLoading && <div>Loading...</div>}
      {!isLoading && hasError && (
        <div>Si è verificato un errore nel reperimento dati</div>
      )}
      {!isLoading && response && (
        <React.Fragment>
          <ul>
            {response.data.map(user => (
              <li key={user.id}>{user.first_name}</li>
            ))}
          </ul>
          <div>
            {[...Array(response.total_pages).keys()].map(index => {
              const page = index + 1;
              return (
                <span onClick={onPageClick(page)}>
                  {page === response.page ? <strong>{page}</strong> : page}{" "}
                </span>
              );
            })}
          </div>
        </React.Fragment>
      )}
    </div>
  );
};

Creazione di un custom hook per generalizzare il recupero dati

Come detto in precedenza, gli hook permettono di definire e condividere dati e funzionalità. Oltre a quelli forniti dal core di React, è possibile creare hook personalizzati.

Quello che mi aggingo a fare, è scorporare la funzionalità di recupero dati dal componente, spostandola su un custom hook che espone tutte le variabili necessarie al componente lista (o ad altri componenti che ne faranno uso).

// Hook personalizzato per il recupero generico di dati paginati
function useFetch({ url, initialPage }) {
  const [response, setResponse] = useState();
  const [isLoading, setIsLoading] = useState(true);
  const [hasError, setHasError] = useState(false);
  const [currentPage, setCurrentPage] = useState(initialPage);

  useEffect(() => {
    const fetch = async () => {
      setHasError(false);

      try {
        const { data } = await ClientHTTP(`${url}?page=${currentPage}`);
        setResponse(data);
      } catch (error) {
        setHasError(true);
      }
      setIsLoading(false);
    };

    fetch();
  }, [currentPage]);

  // Restituisce all'esterno lo stato della richiesta e i dati recuperati
  // e la funzione per settare la pagina corrente
  return [{ isLoading, hasError, response }, setCurrentPage];
}

export default useFetch;

Un hook personalizzato è una funzione il cui nome inizia con use; all’interno definisco lo stato, l’effetto (con le eventuali variabili da cui dipende) e i dati da restituire all’esterno.

Da notare che l’effetto è invocato ogni volta che il valore di currentPage cambia e, come si può intuire, al primo render del componente che lo usa.

Dopo aver eliminato tutte le variabili di stato non più necessarie, la definizione hardcoded dell’effetto, ho ottenuto un codice snello ed intuitivo.

const UserList = () => {
  // Utilizzo dell'hook personalizzato
  const [{ isLoading, hasError, response }, setCurrentPage] = useFetch({
    url: "/api/my-users-endpoint"
  });

  function onPageClick(page) { ... }

  return (
    ...
  );
};

È chiaro come un approccio del genere porti al riutilizzo del codice e a semplificare nuovi sviluppi similari. Alla fine, la gestione del recupero dati si è tradotta in un paio di righe di codice.

Ottimizzazioni

Alcune piccole ottimizzazioni, possono rendere hook custom e componentistica ancor più scalabili.

Hook di reperimento dati con data provider e parametri arbitrari

L’hook useFetch consente di impostare l’url base di un API endpoint (all’inizializzazione) e di aggiornare la pagina corrente in qualsiasi momento.

Questo approccio molto semplicistico presenta alcune criticità:

  • Non è possibile passare altri parametri arbitrari al di fuori del numero di pagina (ad esempio parametri di filtraggio dati o identificativi di risorsa);
  • Vincola l’uso di uno specifico client HTTP (la nostra libreria che gestische le richieste è dichiarata all’interno dell’hook);
  • Non consente di configurare il client HTTP (ad esempio per passare specifici attributi negli header della richiesta).

Generalmente, creo funzioni di utility che mi gestiscono il reperimento dati, raggruppando le stesse per entità in uno specifico data provider.

Questo mi consente di poter decidere e configurare il client e, più in generale, di avere all’occorrenza una gestione separata (e specifica) per ogni rotta.

Per rendere l’idea, questo è lo scheletro di un ipotetico UserProvider che fornisce i metodi per effettuare operazioni CRUD sull’entità utente:

const BASIC_USERS_URL = "/api/my-users-endpoint";

// CREATE
export async function createUser() { ... }

// READ
export async function getUsers(params) { ... }
export async function getSingleUser({ id }) { ... }

// UPDATE
export async function updateUser({name, email, ...}) { ... }

// DELETE
export async function deleteUser({ id }) { ... }

Dove l’implementazione di una specifica funzione potrebbe essere banalmente:

export async function getUsers(params) {
  const data = await MyClientHTTP(BASIC_USERS_URL, params);
  return data;
}

Il provider conosce l’url dell’endpoint. Ogni suo metodo maschera il client HTTP che genera le richieste (ed eventuali logiche di recupero dati annesse) fornendo un pratico livello di astrazione dati.

In questa ottica, modifico useFetch nel seguente modo:

function useFetch({ providerFunc, initialParams }) {
  const [response, setResponse] = useState();
  const [isLoading, setIsLoading] = useState(true);
  const [hasError, setHasError] = useState(false);
  const [params, setParams] = useState(initialParams);

  useEffect(() => {
    const fetch = async () => {
      setHasError(false);

      try {
        const { data } = await providerFunc(params);
        setResponse(data);
      } catch (error) {
        setHasError(true);
      }
      setIsLoading(false);
    };

    fetch();
  }, [params]);

  return [{ isLoading, hasError, response }, setParams];
}

La funzione riceve il metodo del provider ed eventuali parametri iniziali da appendere alla richiesta. L’effetto non più è invocato al cambio della currentPage (valore rimosso), bensí ad un più generico cambio di parametri nell’url.

Aggiorno conseguentemente il componente UserList:

const UserList = () => {
  // Consigura l'hook passando la specifica funzione di recupero dati
  const [{ isLoading, hasError, response }, setParams] = useFetch({
    providerFunc: getUsers
  });

  function onPageClick(page) {
    // Richiama setParams con il nuovo valore della pagina
    return () => setParams({ page });
  }

  return (
    ...
  );
};

Puoi trovare il codice finora discusso su Codesandbox:

Edit Data fetching with React hooks V1

insieme ad una versione integrata con i componenti di Ant design

Edit Data fetching with React hooks V2 (with antd comps)

Altre ottimizzazioni avanzate

Per amor di completezza, elenco alcune ottimizzazioni che aiuterebbero a rendere l’hook finora creato molto più flessibile, performante e completo:

  • Gestione della richiesta con debounce: ovvero la possibilità di temporizzare la richiesta in modo da effettuare al massimo una chiamata in uno specifico intervallo di tempo. Opzione molta comoda ad esempio su un componente di autocomplete, dove si scatenerebbe una richiesta ad ogni evento onChange (alias ad ogni lettera digitata);
  • Annullamento della richiesta tramite le funzionalità di AbortSignal;
  • Disponibilità dei dati precedente reperiti a fronte di un nuovo recupero dati: immaginate ad esempio un infinite scrolling;
  • Avvio della richiesta di recupero dati al verificarsi di un dato evento (e non al mount del componente!);
  • Possibilità di forzare l’aggiornamento di dati: ad esempio al click di un pulsante di refresh;

Conclusioni

In questi anni, sono stati utilizzati vari design pattern per il recupero dati: dal reperimento autonomo su componente Classe, ad implentazioni di HOC e componenti RenderProp.

Il recupero dati tramite hook, è sicuramente la soluzione più moderna e più in linea alle nuove prospettive di React. La vera (grande) differenza dai classici metodi del lifecycle, è che possiamo considerare il recupero dei dati come un effetto collaterale alla modifica dei dati nel componente.

Provando il pattern in scenari differenti, ho raccolto qualche pro e contro.

PRO:

  • L’uso di hook in sè porta ad una significativa riduzione del codice;
  • È possibile recuperare i dati in modo dichiarativo;
  • Il codice per impostare una richiesta e assicurarsi che venga rieseguita opportunamente è ora gestito in un unico posto;
  • Nascondere la gestione del recupero dei dati asincroni in un custom hook rende l’app molto più facile da leggere;
  • È possibile usarlo in qualsiasi componente funzionale (e non) senza duplicare la funzionalità tra i differenti componenti Class (come accadeva in precedenza);
  • Gran parte delle librerie React stanno fornendo le loro utility tramite hook;

CONTRO:

  • L’hook effect viene sempre eseguito al primo rendering del componente che lo utilizza. In particolari contesti, questo comportamento non è voluto. Per esempio, nel caso in cui si vogliano caricare i dati solamente al verificarsi di un dato evento (es. click utente); La strategia di recupero dati con l’hook finora creato, non darebbe buoni esiti (avremmo una chiamata iniziale non voluta) a meno che si aggiunga una logica che impedisca (arbitrariamente) l’esecuzione della prima chiamata (ad esempio controllando che il componente sia già stato precedentemente montato).
  • Ci sono molte proposte di implementazione per il recupero dati (e non una ufficiale);
  • Infine, implementarne una propria (completa) potrebbe non essere cosí semplice.

Per quest’ultimo motivo, dopo aver provato varie librerie, ho trovato due progetti interessanti:

  • react-async-hook
  • swr: quest’ultimo aggiunge la gestione di un layer di cache, estremamente utile in contesti di applicazioni molto interattive e complesse. Lo reputo un progetto promettente, motivo per cui attualmente utilizzo la libreria su alcuni progetti lavorativi.

Pro e contro citati sono dettati dai miei primi tentativi di sviluppo e integrazione in un progetto reale. Potrebbero quindi variare con l’uso intensivo del pattern.

Malgrado non ci sia una proposta di implementazione ufficiale da parte del team di React (la speranza è che arrivi a breve), reputo che sia sensato iniziare ad usare questo nuovo pattern nei progetti lavorativi quotidiani considerando che, l’introduzione degli hook, non porta con sè nessun breaking change ed è possibile (con qualche espediente), utilizzarli anche in componenti di Classe precedentemente sviluppati.

React.js
Hooks
Recupero dati
Componenti