Componenti dichiarativi in React | Aulab

GUIDE PER ASPIRANTI PROGRAMMATORI

Componenti dichiarativi in React

Nel corso di questa guida abbiamo creato un componente, Query, che gestisce le richieste di rete di tipo query al suo interno. La versione “successful” dell’interfaccia, però, è un paragrafo che dice “Success!” senza utilizzare i dati della risposta in nessun modo, e la parte di creazione degli elementi (gestita da TodoItemForm) è completamente sganciata…

Lezione 29 / 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!

Nel corso di questa guida abbiamo creato un componente, Query, che gestisce le richieste di rete di tipo query al suo interno. La versione “successful” dell’interfaccia, però, è un paragrafo che dice “Success!” senza utilizzare i dati della risposta in nessun modo, e la parte di creazione degli elementi (gestita da TodoItemForm) è completamente sganciata dal resto dell’applicazione.

Il problema che abbiamo, concettualmente, è che la richiesta di rete non è aggiornabile: una volta che la risposta è arrivata, non possiamo modificarla. In un’applicazione reale, in cui abbiamo a disposizione un server con un’ API che possiamo chiamare, una soluzione potrebbe essere richiamare la API dopo ogni aggiornamento: se un elemento viene aggiunto, modificato o eliminato, eseguiamo di nuovo la nostra query, che risolverà con i dati aggiornati.

Questo approccio è un classico delle applicazioni, tanto che molti endpoint che ricevono richieste che modificano i dati (quelle con metodo POST, PUT/PATCH o DELETE), molto spesso non rispondono con dei dati (il nuovo elemento, lo stato dell’elemento dopo la modifica, l’elemento che è stato appena eliminato) ma rispondono con “ok, è andato tutto bene” senza inviare altre informazioni.

La nostra funzione mock/sendNetworkRequest risponde sempre con i dati, per cui possiamo simulare di avere un server che, quando creiamo un elemento, ci risponde con lo stato del nuovo elemento appena creato.

L’unica cosa che ci manca è una versione di useQuery che tratti la prima richiesta di rete come un modo per recuperare lo stato iniziale vero e proprio dell’applicazione. Una volta che i dati iniziali sono stati recuperati, noi penseremo a inviare le richieste di rete al server per la creazione, modifica ed eliminazione di dati. Il server ci risponderà con il nuovo stato del singolo elemento e noi aggiorneremo lo stato della query di conseguenza.

Mappare una richiesta di rete in React

Come abbiamo accennato in precedenza, in programmazione funzionale, map non è solo un metodo di Array ma un’operazione che trasforma un “contenitore di qualcosa” in un “contenitore di qualcos’altro”. Abbiamo visto l’esempio di Array.map, che trasforma una lista di A in una lista di B accettando una funzione che trasforma A in B, per poi applicarla a ogni elemento della lista. Abbiamo visto l’esempio di Promise.then, che accetta una funzione che trasforma A in B, che applicherà a una Promise dopo che sarà stata risolta (con un risultato A), e restituisce una Promise che sarà risolta con un risultato B.

Possiamo utilizzare la sintassi di TypeScript per rappresentare questi due esempi. Se non conosci TypeScript, non preoccuparti, per due motivi: innanzitutto abbiamo pronta per te una pratica guida a Typescript in italiano e, in secondo luogo, la sintassi dovrebbe essere intuitiva:

interface Array<T> {
  map(this: Array<A>, mapFn: (a: A) => B): Array<B>;
}

interface Promise<T> {
  then(this: Promise<A>, mapFn: (a: A) => B): Promise<B>;
}

In entrambi gli esempi, abbiamo chiamato “qualcosa” A e “qualcos’altro” B. Quando parliamo di Array, il “contenitore” è Array e quando parliamo di Promise, il “contenitore” è Promise. Sia map che then trasformano il “contenitore” di “qualcosa” in un “contenitore” di “qualcos’altro”, ed entrambe accettano mapFn, che è una funzione che trasforma “qualcosa” in “qualcos’altro” (A in B).

Se pensiamo al concetto di contenitore, possiamo renderci conto che anche la nostra classe NetworkRequest è un contenitore! Ogni volta che usiamo useQuery o useCommand, stiamo “abbracciando” una richiesta di rete in un contenitore. Il “qualcosa” che cambia da richiesta a richiesta è il formato dei dati che arriveranno dalla rete, nel nostro caso la lista di cose da fare (in App) o l’elemento aggiornato (in TodoItemForm).

Mentre Array.map funziona sempre, perché Array non prevede stati, Promise.then funziona solo se la Promise risolve, mentre non funzionerà in caso di errore. Promise prevede tre stati (“pending” cioè in caricamento, “resolved” cioè risolta/successo, “rejected” cioè rigettata/fallimento). NetworkRequest ne prevede quattro, tre sono praticamente identici a quelli di Promise e poi abbiamo “idle” (inattivo).

NetworkRequest.map avrà quindi un funzionamento molto simile a quello di Promise, con una differenza per semplicità: mentre possiamo chiamare Promise.then anche quando lo stato di Promise è “pending” (caricamento), e then verrà correttamente chiamata in caso di successo, NetworkRequest.map funzionerà solo se la NetworkRequest sarà già in stato “succeeded”.

// File: src/hooks/NetworkRequest.js

export class NetworkRequest {
  // ...

  map(mapFn) {
    return this.match({
      whenIdle: () => this,
      whenLoading: () => this,
      whenFailure: () => this,
      whenSuccessful: (data) => {
        const mappedData = mapFn(data);
        return NetworkRequest.#make(new SuccessfulNetworkRequest());
      },
    });
  }
}

Sfruttiamo il metodo match, che ci rende molto comodo gestire tutti i possibili stati. In caso di stati diversi da “successful”, non facciamo niente, restituendo la stessa NetworkRequest così com’è.

In caso di stato “successful”, passiamo i dati della risposta a mapFn, che li traforma in “qualcos’altro”. Inseriamo il “qualcos’altro” in una nuova chiamata di rete in stato “successful”.

useQueryState

Ora che possiamo trasformare i dati all’interno di una richiesta di rete che ha avuto successo, possiamo trattare le richieste di rete come se fossero stati. Vediamo il codice della nuova funzione hook useQueryState:

// File: src/hooks/useQueryState.js

import { useState } from "react";
import { NetworkRequest } from "./NetworkRequest";
import { sendNetworkRequest } from "../mock/sendNetworkRequest";

export function useQueryState({ path, initialData }) {
  const [queryState, setQueryState] = useState(NetworkRequest.create());

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

  const setNetworkState = (setStateAction) => {
    setQueryState((state) => {
      return state.map((data) => {
        if (typeof setStateAction === "function") {
          return setStateAction(data);
        } else {
          return setStateAction;
        }
      });
    });
  };

  if (queryState.isIdle()) {
    sendQuery(initialData);
  }

  return [queryState, sendQuery, setNetworkState];
}

se vogliamo un misto tra useCommand e useQuery, ma con un livello in più, la funzione setNetworkState.

Non possiamo usare useCommand perché utilizza la funzione setter, per modificare la richiesta, solo internamente. Se date un’occhiata al codice di useCommand, vedrete come setNetworkRequest non viene restituita dalla funzione. Questa è cosa buona e giusta, dato che le risposte alle richieste di tipo command non sono modificabili.

Gli argomenti che accettiamo (path e initialData) sono gli stessi di useQuery. La prima parte, in cui creiamo lo stato queryState e definiamo la funzione sendQuery, è identica a useCommand, solo con nomi diversi. L’ultima parte, in cui chiamiamo sendQuery se queryState è in stato “idle”, è la stessa di useQuery.

La differenza con useQuery e useCommand sta nel fatto che, oltre allo stato della richiesta e alla funzione per inviarla (quella che usiamo come retry), restituiamo una terza funzione setNetworkState.

setNetworkState si comporta come una funzione setter, accettando le stesse forme che accetta una funzione setter: quella con cui passiamo direttamente un valore e quella con cui passiamo una funzione, che riceve il valore corrente e lo usa per calcolare il valore successivo:

    1. setNetworkState(someValue); oppure
  • setNetworkState(currentValue => calculateNextValue(currentValue));

Ritroviamo queste due forme dentro la funzione setNetworkState:

if (typeof setStateAction === "function") {
  // Forma 2.
  return setStateAction(data);
} else {
  // Forma 1.
  return setStateAction;
}

Il resto della funzione setNetworkState non fa altro che prendere il nuovo valore dello stato, usare NetworkRequest.map per rimpiazzarlo all’interno della richiesta di rete e sostituire lo stato corrente della richiesta di rete con il successivo (tramite setQueryState).

Rendering dei risultati della query

useQueryState ci permetterà di ricollegare la parte di creazione degli elementi (gestita da TodoItemForm) al resto dell’applicazione. Ora risolviamo l’altro problema, quello per cui la versione “successful” dell’interfaccia è un paragrafo che dice “Success!” senza utilizzare i dati della risposta in nessun modo.

Il modo più elegante di risolvere il problema sarebbe utilizzando children, così da poter scrivere qualcosa tipo:

<Query path="/todo" networkRequestState={todoListNetworkRequest} retry={retry}>
  {/* qui magicamente avere a disposizione todoList */}
</Query>

Una cosa del genere si può fare, utilizzando React.Children e decidendo che Query può avere un unico figlio con una prop che abbia un valore ben definito (per esempio data). Il linguaggio JavaScript, però, non è il linguaggio di programmazione migliore per le convenzioni, perché non abbiamo a disposizione un compilatore che ci anticipi gli errori.

Una soluzione alternativa e più semplice è usare una funzione come prop. Sappiamo che le prop, a differenza degli attributi HTML che devono essere stringhe, possono essere di qualsiasi tipo. Decidiamo, quindi, di accettare una funzione, che verrà chiamata quando la query sarà in stato di successo, ricevendo i dati della risposta e restituendo JSX. La chiamiamo render:

// File: src/components/Query.jsx

export default function Query({ networkRequestState, retry, render }) {
  return networkRequestState.match({
    whenIdle: () => null,
    whenLoading: () => <p style={{ textAlign: "center" }}>Loading...</p>,
    whenFailure: (error) => (
      <p style={{ textAlign: "center", color: "red" }}>
        Error! {error.message}
        <button onClick={retry}>Retry</button>
      </p>
    ),
    whenSuccessful: (data) => {
      return render(data);
    },
  });
}

Aggiornare le richieste di rete 

È ora di utilizzare la nostra nuova funzione hook useQueryState, per unire la lista iniziale che otteniamo con la query con gli aggiornamenti che otteniamo con il command, nonché la versione aggiornata di Query per utilizzare il risultato della query:

// File: src/App.jsx

import "./App.css";
import Panel from "./components/Panel";
import TodoItemForm from "./components/TodoItemForm";
import TodoList from "./components/TodoList";
import Query from "./components/Query";
import { useQueryState } from "./hooks/useQueryState";

// const initialData = ...

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

  const onTodoItemFormSubmit = (todoItem) => {
    return setTodoList((todoList) => [todoItem, ...todoList]);
  };

  return (
    <div className="App">
      <Query
        path="/todo"
        networkRequestState={todoListNetworkRequest}
        retry={retry}
        render={(todoList) => (
          <>
            <TodoList todoList={todoList} />
            <Panel>
              <TodoItemForm onSubmit={onTodoItemFormSubmit} />
            </Panel>
          </>
        )}
      />
    </div>
  );
}

L’utilizzo di render non è elegante come sarebbe una versione in cui possiamo mettere componenti direttamente dentro Query, ma ci va molto vicino.

Notiamo come Query esegue il rendering di TodoItemForm solo quando la query è in stato di “successful”. Questo significa che le funzioni onTodoItemFormSubmit e, di conseguenza, setTodoList, non saranno mai chiamate con la query in stato di “idle”, “loading” o “failure”. Questo significa che il metodo NetworkRequest.map verrà chiamato esclusivamente se la richiesta di rete ha successo, che è l’unico caso in cui funziona.

Tutto questo non sarebbe possibile se i dati in React non viaggiassero in una sola direzione, dai componenti genitori ai figli. Avendo dato ai genitori la responsabilità di “leggere dalla rete” e ai figli quella di “scrivere”, possiamo fidarci del fatto che non verrà mai eseguita una scrittura se prima la lettura non è andata a buon fine. Appoggiandoci a questa sicurezza, possiamo fidarci del fatto che NetworkRequest.map non fallirà.

UI e stati nei componenti dichiarativi

Abbiamo già individuato una differenza tra componenti controller, che contengono gli stati e la logica che li gestisce, e componenti view (o controllati) che si limitano a mostrare la rappresentazione grafica dello stato (tramite paragrafi, forme, colori, input, bottoni) e notificare i loro componenti controller quando l’utente interagisce con l’interfaccia.

Il rapporto tra App e Query è un esempio perfetto di rapporto tra componente controller e componente view. App si occupa non solo di recuperare i dati dalla rete (tramite useQueryState), ma anche di comunicare con TodoItemForm per gestire la creazione di nuovi elementi. Query, invece, rappresenta graficamente tutti gli stati possibili della richiesta di rete e, quando la richiesta ha avuto successo, diventa una porta aperta per App che fa passare l’interfaccia tramite la prop/funzione render.

C’è anche un altro rapporto che si è sviluppato nel nostro sistema: la macchina a stati finiti NetworkRequest, le funzioni hook useQuery e useQueryState e il componente Query, sono tre punti di vista diversi sulla stessa cosa. NetworkRequest rappresenta il modello di una richiesta di rete, il suo funzionamento interno; le funzioni hook useQuery e useQueryState rappresentano la logica di controllo che lega le richieste di rete ai cicli di rendering di React. 

Il componente Query integra l’interfaccia grafica che rappresenta la richiesta di rete, in cui ogni stato è rappresentato tramite elementi visivi che l’utente associa intuitivamente a quello che sta accadendo. Se conosci il paradigma di model-view-controller (MVC), questo rapporto ti risulterà familiare.

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