
GUIDE PER ASPIRANTI PROGRAMMATORI
Guida React in italiano
Imparerai i concetti fondamentali di React, una delle librerie JavaScript più usate e amate dagli sviluppatori frontend: componenti, gestione dello stato, hooks, JSX, ciclo di vita dei componenti e routing non saranno più in’incognita. Questi elementi ti permetteranno di comprendere come utilizzare React per creare interfacce utente dinamiche e ottimizzate per i tuoi progetti web.


Vuoi avviare una nuova carriera o fare un upgrade?
Trova il corso Digital & Tech più adatto a te nel nostro catalogo!
1
Introduzione a React
1.1
Che cos’ è React
React.js (o semplicemente React) è una delle librerie JavaScript più popolari, e la più popolare al momento della scrittura di questo testo, secondo Stack Overflow, che nel suo sondaggio annuale del 2022 ha raccolto le risposte di più di 58.000 sviluppatori da 179 paesi del mondo.
Secondo Wikipedia, React fu creato da Jordan Walke, un ingegnere del software di Meta. Dopo essere stato utilizzato all’interno di Facebook nel 2011 e di Instagram nel 2012, fu pubblicato per la prima volta nel maggio del 2013.
React è una libreria per il front-end, cioè la parte di applicazione che viene utilizzata dagli utenti. L’utilizzo di React è quello di semplificare lo sviluppo di interfacce utente (User Interfaces o UI) complesse.
Ogni libreria propone più o meno soluzioni per problemi ricorrenti, ma che non sempre fanno parte del linguaggio di programmazione di riferimento (in questo caso, JavaScript).
React si distingue tra tutte perché non propone tanto una grande quantità di soluzioni (anche se, come in molti casi nel mondo di JavaScript, per ogni possibile problema c’è un pacchetto aggiuntivo che può essere opzionalmente installato) quanto un intero metodo di pensiero e di scrittura del codice, basato sulla programmazione dichiarativa.
React e la programmazione dichiarativa
Nella storia della programmazione informatica possiamo trovare centinaia di linguaggi. Questi linguaggi possono essere però fondamentalmente condotti a due grandi paradigmi di programmazione: la programmazione imperativa e la programmazione dichiarativa.
La programmazione imperativa, che si divide in programmazione procedurale e programmazione a oggetti, si esprime tramite istruzioni e procedure: “Fai questa cosa. Ora fai quest’altra cosa. Se quella cosa è vera, allora fai questo, altrimenti fai quello”.
La programmazione dichiarativa, invece - che si divide in funzionale, logica, matematica e reattiva - si esprime descrivendo la realtà e lasciando al sistema il compito di rispondere alle domande.
React si basa sul paradigma dichiarativo, e più specificamente funzionale, che impareremo ad utilizzare nel corso del nostro viaggio.
2
Come installare React
2.1
Come installare React
Per installare React, occorre prima aver installato Node. Consigliamo anche di installare Yarn, un’alternativa a npm molto utilizzata in ambito React. Gli esempi di questa guida saranno comunque fatti utilizzando entrambi i metodi.
Ci sono altre alternative, sia a Node (come Deno e Bun) che a NPM e Yarn (come PNPM e, di nuovo, Bun), che ti invitiamo a prendere in considerazione dopo aver letto questa guida.
React può essere - e, tendenzialmente, viene - installato come parte di un ecosistema che permette di integrare anche un “BFF”, che non sta per “Best Friends Forever” ma per “Back-end For Front-end” e indica una parte del back-end che ha come unico obiettivo quello di inviare dati al front-end. Il sistema di questo tipo più popolare è Next.
Noi però, ai fini di questa guida, ci concentreremo su React per il front-end, per cui useremo un sistema che non prevede l’installazione di back-end o BFF.
Una volta installato Node - ed, eventualmente, Yarn - ci sono due strade possibili. Entrambe forniscono quello che ci serve per sviluppare al meglio le nostre applicazioni React (primo tra tutti: il ricaricamento automatico della pagina quando salviamo un file!); vediamole entrambe:
Create React App: è il pacchetto ufficiale per creare applicazioni React. Rispetto a Vite (vedi punto 2.) è più lento, perché esegue più controlli sulla correttezza del codice. È più indicato per chi sta imparando, proprio perché sottolinea più potenziali errori, e in applicazioni piccole la differenza di velocità è a malapena percepibile.
Vite: è più veloce di Create React App ma non esegue nessun controllo sul codice. Consigliato per le persone più esperte, mette anche a disposizione un builder degli assets che migliora le performance
In ogni caso, il primo passo è uguale per tutti: apriamo un terminale e usiamo il comando cd per spostarci nella cartella in cui vogliamo creare il progetto, oppure troviamo la cartella usando un’app per esplorare i file (Finder, esplora risorse, dipende dal sistema operativo) e apriamo un terminale in quella cartella.
Una volta che abbiamo il terminale che punta alla cartella che vogliamo, scegliamo il nome che vogliamo dare alla cartella.
Immaginiamo che sia app-name (è chiaramente un esempio):
Per Create React App e NPM: npm init react-app app-name
Per Create React App e Yarn: yarn create react-app app-name
Per vite e NPM: npm create vite@latest app-name -- --template react-swc
Per vite e Yarn: yarn create vite app-name --template react-swc
Per approfondimenti, visita la documentazione ufficiale sull’installazione di Create React App e Vite
Una volta installato il tutto, le istruzioni per far partire il progetto appariranno sul terminale. Saranno cd app-name (dove app-name è il nome che hai dato alla cartella) e poi:
Per Create React App e NPM: npm start
Per Create React App e Yarn: yarn start
Per vite e NPM: npm install e poi npm run dev (sono due comandi separati)
Per vite e Yarn: yarn e poi yarn dev (sono due comandi separati)
Create React App, quando viene eseguito yarn start (o npm start) apre automaticamente il browser sulla pagina iniziale del progetto, mentre Vite, con yarn dev e npm run dev, scriverà sul terminale l’URL da inserire nel browser per vedere la pagina iniziale.
Impostazione di default
Prima di continuare, vediamo dove sono le informazioni sulla nostra app e quali file possiamo eliminare perché fanno parte dell’esempio iniziale. Prima di seguire la prossima parte, sentiti pure liberoi di aprire i file e leggere un po’ di codice, per prendere confidenza con la sintassi.
Create React App
In public/index.html troviamo la pagina iniziale. Non c’è bisogno di modificare il body in nessun modo (lo faremo tramite React) ma possiamo modificare il contenuto di head, per esempio modificando il titolo della pagina (tag title) o la descrizione.
In public/manifest.json ci sono le informazioni che servono quando l’applicazione viene salvata nella schermata home dei dispositivi che lo permettono. Anche in questo caso, possiamo modificare il titolo (short_name e name).
In src/index.css troviamo le parti di CSS che si applicheranno all’intera app. Il mio consiglio è di lasciarlo così com’è, modificando eventualmente la proprietà font-family di body. Il resto è tendenzialmente applicabile a qualsiasi applicazione.
In src/App.js troviamo l’effettivo contenuto della pagina di esempio. Il nostro consiglio è di eliminare tutto il contenuto di App.css, rinominare il file App.js in App.jsx e, in App.jsx, tenere solo la funzione App facendole restituire null. Vedremo, a breve, cosa significa. Il risultato finale dovrebbe apparire così:
// File: src/App.jsx
import "./App.css";
function App() {
return null;
}
export default App;
A questo punto dovresti avere davanti una pagina bianca. Possiamo procedere a eliminare il file src/logo.svg.
Vite
In index.html troviamo la pagina iniziale. Non c’è bisogno di modificare il body in nessun modo (lo faremo tramite React) ma possiamo modificare il contenuto di head, per esempio modificando il titolo della pagina (tag title) o aggiungendo una descrizione.
In src/index.css troviamo le parti di CSS che si applicheranno all’intera app. Molte delle cose in questo file sono specifiche dell’esempio ma possiamo tenere, se vogliamo, alcune regole di :root e body che potrebbero essere utili. Attenzione alla media query @media (prefers-color-scheme: light), che inverte i colori per i sistemi con il tema chiaro: nel caso in cui dovessi mantere quella regola e dimenticartene, e dovessi usare colori con valore fisso come #000 o #fff per i componenti interni, potresti trovarti con testi neri su fondo nero o bianchi su fondo nero senza saperlo. Insomma, se decidi di tenere quella regola attiva, ricordati di testare la tua applicazione con un tema scuro e uno chiaro.
In src/App.jsx troviamo l’effettivo contenuto della pagina di esempio. Il nostro consiglio è di eliminare tutto il contenuto di App.css e, in App.jsx, e tenere solo la funzione App facendole restituire null. Vedremo a breve cosa significa. Il risultato finale dovrebbe apparire così:
// File: src/App.jsx
import "./App.css";
function App() {
return null;
}
export default App;
A questo punto dovresti avere davanti una pagina bianca. Possiamo procedere a eliminare il file src/assets/react.svg.
2.2
Impostazione di Visual Studio Code
Ognuno ha il suo editor preferito, il nostro è Visual Studio Code che è anche uno dei più utilizzati sul pianeta. In questa parte ti suggeriremo l’impostazione ottimale di VS Code per sviluppare applicazioni React.
ESLint
ESLint è un’estensione di VS Code che sottolinea errori nel codice che non ne compromettono il funzionamento, ma lo rendono meno leggibile e meno standard.
È utilizzato negli ambienti in cui si lavora in gruppo ed è molto utile per ridurre il debito tecnico, che è l’insieme delle cose fatte in fretta, che non andrebbero bene ma si fanno per mancanza di tempo, che con il tempo si accumulano inesorabilmente fino a diventare un problema.
L’estensione ESLint di VS Code dovrebbe essere installata di default, ma alcune persone la disabilitano. Create React App fa girare ESLint sul codice a ogni salvataggio e mostra sul terminale eventuali errori o avvertimenti.
Prettier
Prettier è un pacchetto che tiene il codice formattato in conformità allo standard internazionale. Con “formattato” intendiamo cose come l’utilizzo degli apici (') o dei doppi apici (") per le stringhe, oppure se mettere o no spazi prima o dopo le parentesi o le virgole.
È utilizzato negli ambienti in cui si lavora in gruppo per dare una forma comune al codice scritto da tutti, evitando che ogni persona porti le sue preferenze e gusti personali al modo in cui viene scritto il codice. Anche l’estensione di Prettier per VS Code dovrebbe essere installata di default.
Configurazione dell’ambiente di lavoro
Visual Studio Code ha tre livelli di configurazione:
quella di default
quella dell’utente
quella dell’area di lavoro.
La configurazione dell’area di lavoro vince su quella dell’utente e quella dell’utente vince su quella di default. Con “vince” intendiamo che se due configurazioni dicono due cose che si contraddicono, quella che vince prevarrà, appunto, sull’altra.
Dato che la configurazione dell’area di lavoro vince su tutte, possiamo impostare l’area di lavoro per rendere obbligatorie alcune funzionalità così che, se qualcuno si trovasse a lavorare con noi, si troverebbe ad ereditare queste funzionalità.
Ci sono vari modi per impostare la configurazione dell’area di lavoro, tra cui quella di creare una cartella .vscode nella radice del progetto (allo stesso livello di src per intenderci) e inserire due file.
Il primo file - che si chiamerà settings.json - contiene le impostazioni che vogliamo impostare. Ad esempio:
{
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true,
"editor.tabSize": 2,
"files.eol": "\n",
"files.insertFinalNewline": true,
"files.trimTrailingWhitespace": true
}
Vediamole una per una:
"editor.defaultFormatter": "esbenp.prettier-vscode" imposta Prettier come incaricato ufficiale di formattare tutti i file.
"editor.formatOnSave": true istruisce VS Code a far partire Prettier sui file ogni volta che vengono salvati. Se non sei abituato a questa cosa, all’inizio sarà un po’ strano vedere i file cambiare quando lo salverai, ma ti garantiamo che ci si abitua in fretta.
"editor.tabSize": 2 :imposta VS Code per usare due spazi per ogni tab. Questa preferenza è del tutto soggettiva, l’importante è che ciò che imposti sia uguale per tutte le persone che lavorano insieme allo stesso progetto.
"files.eol": "\n" :imposta il carattere del “ritorno a capo”. Questa impostazione elimina una serie di grattacapi quando persone con sistemi operativi diversi (per esempio Windows e MacOS) lavorano sullo stesso codice.
"files.insertFinalNewline": true :istruisce VS Code ad aggiungere una riga vuota alla fine di ogni file. Questa è una buona pratica perché alcuni strumenti più antiquati funzionano male se i file non finiscono con una riga vuota.
"files.trimTrailingWhitespace": true chiede a VS Code di eliminare eventuali spazi alla fine delle righe. Un classico problema fastidioso con i file che contengono codice è quello degli spazi extra all’interno delle righe vuote e alla fine delle righe di codice, che con questa impostazione vengono rimossi in automatico.
Per completezza, ecco un esempio di caso tipico in cui vengono dimenticati spazi in punti del codice:
function getSomething(input) {
const someComputedValue = someCondition;
// <- qui
return {
someKey: someValue, // <- qui
someComputedValue, // <- qui
};
}
Il secondo file - che si chiamerà extensions.json - contiene le estensioni raccomandate:
{
"recommendations": ["esbenp.prettier-vscode", "dbaeumer.vscode-eslint"],
"unwantedRecommendations": []
}
In questo caso stiamo semplicemente chiedendo a VS Code di chiedere al suo utente di installare e/o attivare le estensioni di Prettier e ESLint, quando entra in contatto con il nostro progetto per la prima volta.
TODO e FIXME
Per molti sviluppatori seguire un flusso coerente nella scrittura del codice può rivelarsi meno semplice di quanto si immagini: hai presente quando stai facendo qualcosa, ma ti rendi conto che c’è qualcos’altro che vorresti fare e, allora. cominci a farlo e ti dimentichi di cosa dovevi fare in principio?
Questa cosa ad alcuni accade spesso, ma la buona notizia è che, almeno per gli sviluppatori, c’è una soluzione!
La nostra soluzione è quella di adottare un’estensione per VS Code che tenga traccia, tramite dei commenti, delle cose da fare (// TODO:) e di quelle da sistemare (// FIXME:).
Se cerchi “todo” tra le estensioni di VS Code, troverai una serie di estensioni che fanno questa cosa.
A questo punto, prima di lasciare una cosa a metà e passare alla prossima, puoi piazzare un bel commento che ti ricorda cosa stavi facendo, da rintracciare prontamente alla fine della catena di distrazioni, per non lasciare indietro nulla.
3
JSX
3.1
Che cos'è JSX?
In React, una delle caratteristiche fondamentali è l'uso di JSX, una sintassi che permette di scrivere codice simile a HTML all'interno di JavaScript. JSX sta per JavaScript XML e consente di "mischiare" HTML e JavaScript in modo intuitivo, facilitando la creazione di interfacce utente complesse.
Vediamolo scrivendo il nostro primo componente React! Se hai seguito la parte di creazione dell’app, dovresti avere un file che si chiama App.jsx e contiene una funzione:
// File: src/App.jsx
function App() {
return null;
}
In React, ogni componente è una funzione che restituisce JSX o null.
JSX sta per JavaScript Extention e permette di “mischiare” HTML e JavaScript seguendo le dovute convenzioni (lo vedremo nelle prossime sezioni.); null in JSX significa “nessun contenuto”.
I componenti React sono distinti dalle funzioni JavaScript native e dagli elementi HTML dal fatto che il loro nome inizia con una lettera maiuscola. I file che contengono componenti React possono essere distinti dai file nativi JavaScript tramite l’estensione .jsx.
Vite crea il file App.jsx, mentre Create React App lo chiama App.js, ma possiamo rinominare il file creato da Create React App in App.jsx.
In questo momento, App è un componente che restituisce “nessun contenuto” e, di conseguenza, vediamo una pagina vuota. Proviamo a modificare il valore restituito dal componente App:
// File: src/App.jsx
function App() {
return <p>Hello React!</p>;
}
Prova a testare questo codice, cosa succede?
Esatto, abbiamo il paragrafo "Hello React!"!
Nel file App.jsx c’è anche questa riga:
// File: src/App.jsx
import "./App.css";
Vedremo come funzionano import ed export nelle prossime sezioni, ma possiamo già dire che la riga di cui sopra sta importando il file App.css, che viene trattato proprio come codice CSS. Possiamo provare allora a impostare lo stile del nuovo paragrafo restituito da App:
/* File: src/App.css */
p {
background-color: #ddd;
border-radius: 8px;
margin-left: auto;
margin-right: auto;
max-width: 45em;
padding: 1em;
width: 90vw;
}
*immagine*
Questo, sarà il risultato con il CSS applicato.
Attenzione: a differenza di altri framework, in cui è possibile isolare il codice CSS così che si applichi solo ad un componente, nella libreria React tutto il codice CSS viene applicato a tutti i componenti. Questo significa che, se creassimo un secondo componente, diverso da App, che contiene un paragrafo, lo stile che abbiamo dato a <p> sarebbe applicato anche al paragrafo contenuto in quel componente.
Per rimediare a questo problema, possiamo includere il nostro paragrafo in un <div>, che rappresenta il contenitore del nostro componente App. In React, è molto comune creare un contenitore per i componenti, che abbia una classe con lo stesso nome del componente stesso:
// File: src/App.jsx
function App() {
return (
<div className="App">
<p>Hello React!</p>
</div>
);
}
/* File: src/App.css */
.App p {
background-color: #ddd;
border-radius: 8px;
margin-left: auto;
margin-right: auto;
max-width: 45em;
padding: 1em;
width: 90vw;
}
Due cose da notare, che rivedremo nelle prossime sezioni:
In React, usiamo className al posto del classico class del linguaggio HTML. Questo succede perché, dietro le quinte, React converte JSX in JavaScript e class, in JavaScript, è una parola soggetta a restrizioni.
Abbiamo circondato la parte di JSX (il valore restituito dalla funzione/componente App) tra parentesi tonde. Questo serve a tracciare un confine tra JavaScript e JSX. Se state usando Prettier con l’impostazione di VS Code "formatOnSave", ci penserà l’estensione ad aggiungere le parentesi quando servono, in ogni caso una buona regola è: “ogni volta che una parte di JSX occupa più di una riga, va circondata da parentesi tonde”.
3.2
Valori primitivi in JSX
Abbiamo creato il nostro primo componente, costituito da un elemento div che contiene un paragrafo p, e abbiamo applicato dello stile tramite linguaggio CSS.
Fin’ora, però, abbiamo trattato JSX come se fosse codice HTML tradizionale. È ora di mischiarvi un po’ di linguaggio JavaScript!
Cominciamo con qualcosa di semplice: estraiamo il nostro messaggio (“Hello React!”), inseriamolo in una costante e iniettiamo la costante nel valore di ritorno della funzione.
Spoiler alert: mentre per passare da JavaScript a JSX usiamo parentesi tonde, per passare da JSX a JavaScript usiamo parentesi graffe.
/* File: src/App.jsx */
function App() {
const message = "Hello React!";
return (
<div className="App">
<p>{message}</p>
</div>
);
}
Il codice, in realtà, si spiega da solo, ma proviamo a spiegarlo ugualmente: abbiamo creato una costante, che abbiamo chiamato message, in cui abbiamo inserito il messaggio "Hello React!". La costante è stata poi iniettata nella parte di JSX che la funzione restituisce, tramite l’utilizzo di parentesi graffe ({message}), chiamato Curly Brackets o Bracket Interpolation. Se ci pensi, non è molto diverso dall’utilizzo di string template e template literals in JavaScript, in cui possiamo usare la sintassi ${} per scrivere cose come:
const name = "Andrea";
const greeting = `Hello, my name is ${name}!`;
Invece di una stringa abbiamo JSX e invece di ${} usiamo {}.
Vediamo come i differenti tipi nativi di JavaScript vengono interpretati in JSX:
Stringhe (string): vengono iniettate, come abbiamo appena visto
Numeri (number): vengono trasformati in stringhe, in base 10, e poi iniettati
Booleani (boolean): non vengono stampati (diventano stringhe vuote).
null: rappresenta l’assenza di contenuto
undefined: non viene stampato (diventa una stringa vuote).
Importante: da un punto di vista pratico, null, true, false e undefined provocano lo stesso risultato in JSX, cioè un’assenza di contenuto. Tuttavia, la convenzione vuole che, in React, solo null rappresenti l’assenza di contenuto. Utilizzare true, false o undefined come contenuto in JSX è, quindi, sbagliato.
3.3
JSX VS HTML: le differenze
Mettiamo da parte per un momento la nostra app e vediamo degli esempi di differenze tra JSX e codice HTML tradizionale. JSX viene trasformato in linguaggio JavaScript da React e, per questo, porta delle limitazioni.
Commenti
Come abbiamo visto, usiamo le parentesi tonde per passare da linguaggio JavaScript a JSX e le parentesi graffe per passare da JSX a linguaggio JavaScript. Se vogliamo inserire commenti in JSX, possiamo usare la sintassi HTML:
function MyComponent() {
const message = "Hello!"
return (
<div className="MyComponent">
<!-- Commento -->
<p>{message}</p>
</div>
)
}
Il commento HTML, però, apparirà in pagina! Sarà visibile tramite l’inspector del browser e farà parte in tutto e per tutto del codice HTML della nostra applicazione. Se, invece, vogliamo inserire un commento che non apparirà in pagina, possiamo “passare in modalità JavaScript” e usare la sintassi JavaScript:
function MyComponent() {
const message = "Hello!";
return (
<div className="MyComponent">
{/* Commento */}
<p>{message}</p>
</div>
);
}
Tag senza chiusura
Nel linguaggio HTML, ci sono tag che si aprono e si chiudono (<div></div>, <p></p>) e tag che, invece, non si chiudono (<input>, <img>) perché non possono avere contenuti.
In HTML, per i tag che non si chiudono, utilizziamo la sintassi <tag> (intendiamo dire che utilizziamo il nome del tag tra le parentesi angolari, ad esempio <img>).
In JSX, dobbiamo chiudere tutti i tag vuoti sul posto, scrivendo per esempio <img /> o <input />. La buona notizia è che possiamo chiudere qualsiasi tag, per cui, se vogliamo creare un div vuoto, possiamo scrivere <div />.
Fragment
React non permette a un componente di restituire più di un elemento, perché trasforma JSX in JavaScript e, come sappiamo, una funzione JavaScript non può restituire più di un valore.
In JSX abbiamo un tag particolare, chiamato Fragment, che si rappresenta con <></>. Questo tag viene utilizzato per ottenere un elemento unico senza aggiungere elementi che potrebbero cambiare il significato semantico o funzionale di quello che stiamo facendo. Per esempio:
function MyComponent() {
// Errore!
return (
<p>Look, a message!</p>
<p>And another message!</p>
);
}
Nell’esempio soprastante, il componente MyComponent ha due radici, che sono i due paragrafi. Potremmo inserire un div che circondi i due paragrafi:
function MyComponent() {
return (
<div>
<p>Look, a message!</p>
<p>And another message!</p>
</div>
);
}
ma il div potrebbe non avere nessuna utilità (semantica o funzionale) se non quella di rispettare la regola di React. Per risolvere questa situazione, utilizziamo un Fragment:
function MyComponent() {
// Corretto!
return (
<>
<p>Look, a message!</p>
<p>And another message!</p>
</>
);
}
Attributi
In linguaggio HTML, i tag non sono altro che marcatori che possono essere configurati tramite coppie attributo-valore:
<input
type="password"
id="password"
name="password"
placeholder="Your password"
disabled
/>
In questo esempio, l’attributo type cambia l’aspetto dell’input, con il valore password che, in particolare, fa sì che ciò che viene scritto al suo interno non sia visibile.
In JSX, funziona allo stesso modo, ma dato che gli attributi vengono trasformati in proprietà di oggetti JavaScript, abbiamo qualche eccezione:
class diventa className (perché class in linguaggio JavaScript ha un significato diverso).
Tutti gli attributi tranne quelli che iniziano per aria- o data- vengono trasformati in camelCase (per esempio onClick, onSubmit, className stesso).
Va prestata particolare attenzione agli attributi booleani, ad esempio disabled, che può essere vero, falso o inesistente. Basta inserire il nome dell'attributo per ottenere un valore true, mentre non inserirlo non vuol dire false ma undefined. Per esempio:
function MyComponent() {
return (
<>
<button /> {/* disabled === undefined */}
<button disabled /> {/* disabled === true */}
<button disabled={true} /> {/* disabled === true */}
<button disabled={false} /> {/* disabled === false */}
</>
);
}
Il nostro consiglio in questo caso è riassumibile in un concetto molto semplice: quando si ha tra le mani una variabile booleana, va usata. Se, invece, vuoi che l’attributo sia sempre true, non devi dargli alcun valore. Di contro, se vuoi che sia sempre false, devi esplicitamente assegnargli il valore false:
function myComponent() {
const myBoolean = true;
return (
<>
<button disabled={myBoolean} /> {/* eredita il valore di myBoolean */}
<button disabled /> {/* sempre vero */}
<button disabled={false} /> {/* sempre falso */}
</>
);
}
È sempre una tentazione quella di usare valori non booleani per questo tipo di attributi, perché nel linguaggio JavaScript esistono i valori “truthy” - come le stringhe non vuote o i numeri diversi da zero - e quelli “falsy”, come le stringhe vuote, zero, null e undefined.
Ma in quel caso è un po’ come tendere una trappola a sé stessi e a chi lavora o lavorerà a quel codice. Se vuoi convertire un valore non booleano in un booleano, puoi usare il leggendario “bang bang” (!!value) o, per una sicurezza maggiore, la funzione Boolean (Boolean(value)).
Approfondimento: gli attributi che cominciano per data- e aria- mantengono la loro forma originale per ragioni storiche.
Consiglio utile: se dovessi trovarti a convertire codice HTML tradizionale in JSX, puoi farlo in automatico usando un convertitore.
4
Import ed Export in React
4.1
Import ed Export in React
Torniamo alla nostra applicazione. Nel file App.jsx, a parte il componente che abbiamo creato, ci sono un altro paio di righe: import "./App.css"; ed export default App;. Di seguito attueremo un paragone con Node e CommonJS; nel caso li conoscessi già, puoi saltare la parte successiva che, invece, spiega il funzionamento partendo da zero.
Paragone con CommonJS
module.exports = myValue; diventa export default myValue;
Allo stesso modo, const myValue = require("path") diventa import myValue from "path";
module.exports.named = named; diventa export named;
Allo stesso modo, const { named } = require("path"); diventa import { named } from "path";
Così come con CommonJS, possiamo dichiarare ed esportare qualcosa in un colpo solo, sempre utilizzando export default ed export. Per esempio, export default function myFunction() {}; equivale a module.exports.myFunction = function myFunction() {};
Spiegazione completa
Nel linguaggio JavaScript, come in molti linguaggi di programmazione, è utile dividere il codice in moduli (i.e.: file separati).
Una volta, questo si faceva agganciando i moduli a oggetti globali (tipicamente window con i browser e process con i server Node), dando origine a un incubo di variabili globali di cui era difficilissimo capire il funzionamento senza documentazione.
Per fortuna, oggi le cose sono diverse. Supponiamo di aver scritto una funzione che calcola la somma di numeri:
function sum(a, b) {
return a + b;
}
Questa funzione viene utilizzata continuamente e non ha senso riscriverla ogni volta che ci serve. Possiamo, allora, metterla in un file, esportarla e importarla in altri file, ogni volta che vogliamo, in questo modo:
// Esempio di percorso: src/utils/sum.js
export default function sum(a, b) {
return a + b;
}
// Esempio di percorso: src/index.js
import mySum from "./utils/sum";
const four = mySum(1, 3);
Notiamo tre cose:
Il percorso al file è relativo (comincia per ./). In React, i percorsi assoluti hanno a che fare con pacchetti Node, mentre quelli relativi hanno a che fare con parti dell’applicazione. Per esempio, nel file src/index.js della nostra applicazione possiamo trovare un import dal percorso react-dom/client, che è un pacchetto che è stato installato durante la creazione dell’app (con Create React App o Vite) ed esterno alla nostra applicazione:
// File: src/index.js
import ReactDOM from "react-dom/client";
Non c’è bisogno di specificare l’estensione .js del file. In React, quando importiamo file .js o .jsx, non serve specificare l’estensione. Più correttamente, React dà per scontato che, se non specifichiamo l’estensione di un file, allora stiamo intendendo un file .js o .jsx. Partendo da questa regola, se stiamo importando un file index.js o index.jsx, possiamo omettere completamente il nome del file. Per esempio, per importare un file in ./some-folder/index.js o ./some-folder/index.jsx, possiamo scrivere from './some-folder.
Nonostante la funzione originale sia stata esportata con il nome sum, è stata importata con il nome mySum. Questo è permesso quando si esporta con default. Di contro, si può esportare con default solo una volta per ogni file.
Supponiamo, adesso, di scrivere una seconda funzione, che esegue la moltiplicazione, nello stesso file. Ha ancora senso esportare sum come default? La somma non è più o meno importante dalla moltiplicazione.
Possiamo, allora, decidere di esportare entrambe le funzioni con il loro nome, allo stesso livello:
// Esempio di percorso: src/utils/math.js
export function sum(a, b) {
return a + b;
}
export function product(a, b) {
return a * b;
}
// Esempio di percorso: src/index.js
import { sum as mySum, product } from "./utils/math";
const four = mySum(1, 3);
const alsoFour = product(2, 2);
Abbiamo tolto default, perché non possiamo scrivere export default function sum e anche export default function multiply nello stesso file. È concesso un solo default per file.
Possiamo comunque creare un alias per le funzioni esportate per nome, come nel caso di sum as mySum, con cui potremo usare la funzione sum chiamandola mySum nel file in cui l’abbiamo importata.
Approfondimento: nel primo esempio, verrà creato un oggetto interno del tipo const module = { default: sum };, mentre l’istruzione di import corrisponde a const mySum = module.default;. Nel secondo caso, l’oggetto interno contiene le proprietà il cui nome è indicato nell’istruzione di export: const module = { sum: sum, product: product }; (o per usare una sintassi alternativa: const module = { sum, product };). Quando viene effettuato un import per nome, la sintassi è la stessa della destrutturazione di oggetti: import { sum as mySum, product } from "./utils/math.js" corrisponde a const { sum: mySum, product } = module;.
Import/export in React
Import ed export si possono usare in React come abbiamo appena visto. L’unica regola aggiuntiva è che i componenti dovrebbero essere esportati utilizzando default. Questo perché c’è una tecnica di ottimizzazione, che vedremo alla fine di questa guida, che funziona solo con questa modalità di export.
Import di file non JavaScript
I file JavaScript, che possono contenere componenti React o classi, valori e funzioni JavaScript, non sono gli unici che possiamo importare. Sia Create React App che Vite ci permettono di importare anche file CSS (come abbiamo visto in precedenza), JSON, immagini e SVG.
4.2
Import di immagini in React
Come dicevamo nella sezione precedente, tra i file che possiamo importare in React ci sono anche le immagini. Un esempio è proprio nell’applicazione che Create React App prevede all’installazione:
import logo from "./logo.svg";
export default function App() {
return <img src={logo} alt="React" />;
}
Le immagini e gli SVG vengono importati con un formato particolare, che è una variante di Base64, che può essere dato in pasto all’attributo src di un tag img (come nell’esempio soprastante).
La cartella public
Un metodo alternativo per importare file è quello della cartella public: tutto ciò che è nella cartella public è allo stesso livello di index.html, di conseguenza può essere usato come se fosse nella cartella radice del progetto. Per esempio, un file in public/images/my-image.jpg potrebbe essere usato in questo modo:
export default function MyComponent() {
return <img src="./images/my-image.jpg" alt="My image" />;
}
Durante lo sviluppo, il server di Create React App e Vite modificano i percorsi dei file per far sì che public diventi l’effettiva radice del progetto, per cui possiamo (e dobbiamo!) omettere public/ quando scriviamo i percorsi. Quando il progetto viene ottimizzato per la produzione, il contenuto di public viene effettivamente copiato nella radice del progetto, per cui i percorsi rimangono validi.
Quando usare import e quando usare public?
L’importazione di immagini tramite import e quella tramite la cartella public permettono di ottenere lo stesso risultato, ma ci sono un paio di cose da prendere in considerazione:
Le immagini importate tramite import vengono ottimizzate da React e inserite direttamente nel codice JavaScript. Questo significa che, tipicamente, vengono caricate più velocemente.
Di contro, le immagini importate tramite import non sono raggiungibili al di fuori dell’applicazione. Questo significa che non sono indicizzabili dai motori di ricerca, è più difficile condividere le immagini “da sole” (per esempio inviando un link all’immagine tramite social network) o usarle come copertine per i social o anteprime per le applicazioni di messaggistica.
Per decidere quale delle due tecniche usare, quindi, possiamo chiederci: quanto è importante l’immagine al di là del contesto dell’applicazione?
Se si tratta di icone, per esempio, è molto probabile che abbia più senso importarle direttamente con import.
Quando si parla di copertine di pagine, invece, è più probabile che sia conveniente inserirle nella cartella public, così che possano essere indicizzate e condivise con altre piattaforme.
4.3
Gestione dello stile (CSS) in React
Tornando alla nostra applicazione:
// File: src/App.jsx
import "./App.css";
export default function App() {
const message = "Hello React!";
return (
<div className="App">
<p>{message}</p>
</div>
);
}
Nota: abbiamo spostato export default insieme alla dichiarazione della funzione App. Perché? Non c’è una reale motivazione alla base, è una questione di preferenza nella scrittura del codice.
/* File: src/App.css */
.App p {
background-color: #ddd;
border-radius: 8px;
margin-left: auto;
margin-right: auto;
max-width: 45em;
padding: 1em;
width: 90vw;
}
Immaginiamo di voler mantenere una parte dello style legata a tutti i paragrafi di App, ma isolarne un’altra e legarla solo allo stile del paragrafo che abbiamo inserito in App.jsx.
Così come per il linguaggio HTML, anche in React abbiamo l’attributo style, con cui possiamo assegnare una tantum parti di stile a specifici elementi (nonostante, ricordiamolo, non sia considerata una best practice). A differenza di HTML - in cui style si aspetta una stringa - in React abbiamo un oggetto. E, come ormai d’abitudine con React, tutte le proprietà sono in formato camelCase:
/* File: src/App.css */
.App p {
margin-left: auto;
margin-right: auto;
max-width: 45em;
width: 90vw;
}
// File: src/App.jsx
import "./App.css";
export default function App() {
const message = "Hello React!";
return (
<div className="App">
<p
style={{
backgroundColor: "#ddd",
borderRadius: "8px",
padding: "1em",
}}
>
{message}
</p>
</div>
);
}
Notiamo due particolarità:
Dopo style c’è una doppia coppia di parentesi graffe. Quella esterna indica che stiamo passando da codice HTML a JavaScript; la seconda indica che il valore dell’attributo style è un oggetto. Per capire meglio cosa sta succedendo, possiamo isolare style in una costante e passare quella all’attributo:
// File: src/App.jsx
export default function App() {
const message = "Hello React!";
const style = {
backgroundColor: "#ddd",
borderRadius: "8px",
padding: "1em",
};
return (
<div className="App">
<p style={style}>{message}</p>
</div>
);
}
Nota: abbiamo chiamato la costante style perché è buona norma dare nomi uguali a cose diverse che rappresentano le stesse informazioni, per collegarle concettualmente tra loro. Tuttavia, avremmo potuto chiamare la costante chewingGum invece di style e avrebbe funzionato comunque (ma, ci raccomandiamo, non farlo!)
Le proprietà sono state convertite in camelCase: background-color è diventata backgroundColor e border-radius è diventata borderRadius.
Questo è uno dei pochi casi particolari in cui passiamo valori che non sono stringhe agli elementi HTML. Più avanti, quando cominceremo a costruire i nostri elementi personalizzati, scopriremo che è assolutamente normale passare di tutto e di più ai valori degli attributi.
CSS-in-JS
Quando è nata la programmazione web, il codice che definisce la struttura delle pagine (il linguaggio di markup, tipicamente HTML), quello che ne definisce l’aspetto visivo (stile, quindi tipicamente il linguaggio CSS) e quello che ne definisce la logica di funzionamento (spesse volte il linguaggio JavaScript) erano raccolti tutti insieme nello stesso file.
Con il passare del tempo, la comunità dei programmatori web ha separato il codice rispetto alla sua funzione, dando vita ai file .html, .css e .js separati e collegati tra loro.
L’avvento dei framework e delle librerie per la programmazione front-end come React ha segnato un terzo passaggio in cui, da una parte, abbiamo un’inversione di rotta, con i tre “tipi di codice” che tornano a essere raccolti negli stessi file, dall’altra abbiamo una nuova suddivisione, questa volta per “componenti”.
React ha fatto un passo in questa direzione per cui i componenti, come abbiamo visto, raccolgono l’equivalente delle parti HTML e JavaScript relative allo stesso “argomento” (per esempio un bottone, un menù, un form) lasciando da parte il codice CSS.
La comunità ha, però, messo insieme pacchetti e librerie per chi vuole integrare anche la parte di CSS nei file JSX, sotto un grande cappello con il nome di CSS-in-JS. Ci sono più di 50 librerie online che permettono questa cosa, molte delle quali sono state scritte specificamente per React.
Non affronteremo questo argomento all’interno di questa guida, ma se sei interessato, ti invitiamo a esplorare l’argomento. Basta cercare “CSS-in-JS” su Google.
5
I componenti in React
5.1
Che cos'è un componente in React
Un componente in React è un blocco autonomo e riutilizzabile di codice che rappresenta una parte dell'interfaccia utente. I componenti possono essere semplici, come un pulsante o un'icona, o complessi, come un'intera pagina web.
Ogni componente in React è una funzione o una classe JavaScript che può accettare input arbitrari (chiamati "props", vale a dire “proprietà”) e restituire un elemento React che definisce cosa deve essere visualizzato sullo schermo.
L'importanza dei componenti in React
I componenti sono fondamentali in React per diverse ragioni:
Riutilizzabilità: I componenti permettono di riutilizzare codice in diverse parti dell'applicazione. Ad esempio, un pulsante stilizzato può essere definito come un componente e utilizzato ovunque sia necessario, riducendo la duplicazione del codice e facilitando la manutenzione.
Composizione: I componenti possono essere combinati tra loro per costruire interfacce complesse. Un componente padre può includere uno o più componenti figli, creando una gerarchia di componenti che rappresenta la struttura dell'interfaccia utente.
Isolamento: Ogni componente ha il proprio stato e le proprie proprietà, il che significa che è possibile sviluppare e testare ogni componente in isolamento, senza preoccuparsi dell'intero sistema. Questo facilita il debug e l'aggiornamento del codice.
Gestione dello Stato: I componenti possono gestire il proprio stato interno, permettendo di creare interfacce utente interattive e dinamiche. Lo stato di un componente può essere modificato in risposta agli eventi dell'utente, aggiornando automaticamente l'interfaccia utente per riflettere i cambiamenti.
I componenti React possono avere degli argomenti?
Abbiamo detto che i componenti React sono funzioni che restituiscono JSX, un linguaggio di markup paragonabile a HTML che viene convertito in JavaScript da React.
In JavaScript, le funzioni non hanno solo un valore di ritorno, hanno anche degli argomenti, il loro input.
Ma, quindi, i componenti React possono avere degli argomenti? La risposta è sì, ma i componenti React possono ricevere un solo argomento, che è un oggetto JavaScript e rappresenta l’insieme delle loro proprietà.
Le proprietà dei componenti React corrispondono agli attributi degli elementi HTML.
Immaginiamo di voler creare un componente, Message.
Message è la nostra versione personalizzata di un paragrafo, che potremo “aumentare” con nuove funzionalità più avanti.
Possiamo utilizzare la nostra app, creando una cartella components dentro src. Nella cartella components, creiamo un nuovo file Message.jsx:
// File: src/components/Message.jsx
export default function Message() {
return <p className="Message">Look, a message!</p>;
Nota: non è necessario creare la cartella components per far funzionare React in questo modo, qualsiasi file all’interno di src può essere importato. Creare una cartella per raccogliere i componenti rende solo più ordinato il progetto.
Ora modifichiamo App per utilizzare il nuovo componente: lo importiamo tramite il percorso relativo "./components/Message".
// File: src/App.jsx
import "./App.css";
import Message from "./components/Message";
export default function App() {
return (
<div className="App">
<Message />
</div>
);
}
5.2
Le proprietà in React
// File: src/App.jsx
import "./App.css";
import Message from "./components/Message";
export default function App() {
return (
<div className="App">
<Message />
</div>
);
}
Riprendendo l’esempio del capitolo precedente - che vediamo sopra - notiamo che la pagina, grazie a quel codice, conterrà un paragrafo che dice "Look, a message!". Non abbiamo, però, modo di controllare e modificare il contenuto del messaggio dal componente App; possiamo farlo dall’interno del componente Message. Quindi, modifichiamo Message così che possa ricevere delle proprietà:
// File: src/components/Message.jsx
export default function Message(props) {
return <p className="Message">{props.message}</p>;
}
…e passiamo message come proprietà di Message tramite App:
// File: src/App.jsx
import "./App.css";
import Message from "./components/Message";
export default function App() {
return (
<div className="App">
<Message message="Look, a message!" />
</div>
);
}
Notiamo come la sintassi è incredibilmente simile a quella di un elemento HTML:
<ComponenteReact nomeProp = {valoreProp}>
<elemento-html attributo = "valore" ></elemento-html>
Possiamo, quindi, creare dei parallelismi tra componenti React ed elementi HTML:
I componenti React sono delle funzioni che corrispondono agli elementi HTML.
Il singolo argomento che un componente si aspetta è un oggetto e rappresenta le proprietà di quel componente.
I nomi delle proprietà dell’oggetto-proprietà corrispondono agli attributi HTML.
Di conseguenza, i valori dell’oggetto-proprietà sono i valori degli attributi HTML.
Come nei casi precedenti, possiamo sfruttare JSX per salvare il messaggio in una costante e iniettarlo nel valore di ritorno di App:
// File: src/App.jsx
import "./App.css";
import Message from "./components/Message";
export default function App() {
const message = "Look, a message!";
return (
<div className="App">
<Message message={message} />
</div>
);
}
Destrutturazione delle props in React JavaScript
Per aiutare VS Code (o qualsiasi editor stiamo usando) a capire i nomi delle proprietà che il nostro componente si aspetta, possiamo sfruttare la destrutturazione degli oggetti in JavaScript:
// Esempio di destrutturazione di un oggetto
const someObject = {
one: 1,
two: 2,
three: 3,
};
const { one, two three } = someObject;
// File: src/components/Message.jsx
export default function Message({ message }) {
return <p className="Message">{message}</p>;
}
Attenzione: questo metodo si adatta solo a React JavaScript perché, non essendo un linguaggio tipizzato, destrutturare le prop aiuta l’autocompletamento degli editor. In TypeScript, per esempio, potendo creare un’interfaccia per specificare il tipo di proprietà che ci aspettiamo, ha più senso mantenere il nome props ed esprimere le props come, ad esempio, props.message, per distinguerle da eventuali valori dichiarati all’interno del componente.
Le props come tecnica di condivisione dello stato tra componenti
Come abbiamo accennato in precedenza, React viene utilizzato per descrivere interfacce utente complesse. L’utilizzo delle props è il primo strumento che possiamo utilizzare verso questo scopo, trattandosi di un modo per inviare informazioni dai componenti genitori ai componenti figli.
In questo momento stiamo facendo esempi molto semplici, in cui un’informazione hardcoded (i.e.: il cui valore è scritto direttamente nel codice) è passata a un unico componente figlio. Possiamo, però, immaginare situazioni teoricamente molto più complesse, in cui il cambiamento del valore di un’informazione può scatenare una serie di cambiamenti, che si propagano attraverso un albero di componenti, permettendo all’interfaccia grafica di riflettere visivamente il nuovo valore dell’informazione.
Man mano che vedremo metodi e tecniche per rappresentare e comunicare le informazioni tra componenti, cerchiamo di tenere a mente questa filosofia: stiamo cercando di descrivere la realtà, convertendo le informazioni concettuali in informazioni visive e reagendo ai cambiamenti.
La proprietà children in React
React fornisce tre proprietà sempre valide che sono ref, key e children. Vedremo le prime due più avanti, mentre children rappresenta, come possiamo immaginare, i figli di un componente.
Immaginiamo di voler ricreare il rettangolo con i bordi arrotondati che avevamo nella nostra applicazione di esempio prima di isolare il nostro componente Message. Questa volta non vogliamo metterci dentro solo messaggi, ma vogliamo essere in grado di poterci inserire quello che ci pare.
All’interno della cartella components, possiamo creare un nuovo componente, che chiameremo Panel. Creiamo anche un file CSS associato, per poter dare l’aspetto che vogliamo al nuovo componente.
// File: src/components/Panel.jsx
import "./Panel.css";
export default function Panel({ children }) {
return <div className="Panel">{children}</div>;
}
/* File: src/components/Panel.css */
.Panel {
background-color: #ddd;
border-radius: 8px;
margin-left: auto;
margin-right: auto;
margin-top: 1.5em;
max-width: 45em;
padding: 1em;
width: 90vw;
}
A questo punto, possiamo svuotare App.css, perché abbiamo “trasferito” le proprietà del paragrafo al nostro nuovo componente Panel.
Successivamente, possiamo utilizzare il nuovo componente in App.jsx:
// File: src/App.jsx
import "./App.css";
import Message from "./components/Message";
import Panel from "./components/Panel";
export default function App() {
const message = "Look, a message!";
return (
<div className="App">
<Panel>
<Message message={message} />
</Panel>
</div>
);
}
Notiamo come, in Panel.jsx, abbiamo utilizzato children come se fosse una proprietà qualsiasi, per esempio la proprietà message nel componente Message. All’interno di App.jsx, invece, abbiamo semplicemente inserito il componente Message come figlio di Panel.
Quando usare children
L’utilizzo di children è un’ottima soluzione per pop-up, modali e simili. Attenzione, però, alle situazioni di lavoro di gruppo, in cui altre persone potrebbero non sapere quali componenti possono o non possono essere usati come figli di altri componenti.
Se il tuo componente non è fatto per accettare qualsiasi tipo di contenuto (e normalmente nessun componente lo è), assicurati di inserire dei controlli per fare in modo che il componente si assicuri di ricevere dei figli che può gestire.
Immaginiamo che il nostro componente Panel possa gestire solo componenti Message come figli. React ci fornisce uno strumento per controllare i figli uno per uno, che possiamo mischiare con il fatto che le funzioni JavaScript portano l’informazione del loro nome, in questo modo:
// File: src/components/Panel.jsx
import { Children } from "react";
import "./Panel.css";
export default function Panel({ children }) {
Children.forEach(children, (child) => {
if (child.type.name !== "Message") {
throw new Error("Panel can only accept Message components as children");
}
});
return <div className="Panel">{children}</div>;
}
Con questo controllo, se qualcuno dovesse cercare di inserire componenti che non siano Message in un componente Panel, riceverebbe subito un errore durante lo sviluppo. Come vedremo ripetutamente durante il nostro viaggio, una regola aurea della programmazione informatica è: facciamo in modo che il nostro codice renda più semplice usarlo nel modo giusto, di quanto lo sia usarlo nel modo sbagliato. Questa regola ci aiuta a impostare un contesto che porta i nostri colleghi e noi stessi a modificare le funzionalità esistenti, oppure aggiungerne di nuove, senza creare conflitti con le funzionalità passate.
5.3
Gli eventi in React
Cos è un evento in React?
Abbiamo visto come passare le informazioni dai componenti genitori ai componenti figli, tramite props, ma come possiamo fare il contrario, cioè passare informazioni dai figli ai genitori?
La risposta potrebbe soprenderti: non lo facciamo. In React, le informazioni viaggiano solo in un senso, dai componenti genitori ai componenti figli.
Le informazioni in React viaggiano in una sola direzione e puoi decidere tu come immaginare questa direzione (verso l’alto o verso il basso, in orizzontale, in diagonale o a spirale). In questa sede immaginiamo le informazioni viaggiare dall’alto verso il basso, perché visualizziamo i componenti come fossero distribuiti come un diagramma di flusso, ma tu potresti visualizzarli come un albero, con le radici in basso e le informazioni che viaggiano verso l’alto: non ha alcuna importanza. L’importante è che, presa una direzione, le informazioni viaggino sempre e solo in quella direzione, che è dai componenti genitori ai componenti figli.
Il compito dei genitori è quello di inviare informazioni ai figli, quello dei figli è di notificare i genitori in caso di cambiamenti. Queste “notifiche” si inviano tramite eventi.
Gli eventi che accadono più spesso sono quelli che arrivano dal DOM (Document Object Model, cioè l’insieme degli elementi HTML). Ogni movimento del mouse, click o tocco sullo schermo, tasto premuto o rilasciato dalla tastiera, genera un evento: è compito della nostra applicazione decidere quali sono importanti per il suo funzionamento e quali non lo sono.
Quando decidiamo che un evento ci interessa, decidiamo di ascoltarlo, collegando un event listener (ascoltatore di eventi) all’elemento HTML e all’evento che ci interessa. Ogni listener è collegato a una coppia di un evento e un elemento HTML.
Per esempio, potremmo essere interessati al click di un bottone che recita “Click me!”. Non siamo interessati a tutti i click, né a tutti i bottoni, solo ai click su quel particolare bottone, cioè alla coppia bottone “Click me!” ed evento click.
Modifichiamo, quindi, Message per mostrare un bottone. Manteniamo la proprietà message, che diventa l’etichetta del bottone.
// File: src/components/Message.jsx
export default function Message({ message }) {
return <button className="Message">{message}</button>;
}
Ora aggiungiamo un event listener al click del bottone:
// File: src/components/Message.jsx
export default function Message({ message }) {
return (
<button
className="Message"
onClick={() => {
console.log("The button was clicked!");
}}
>
{message}
</button>
);
}
Nota: se hai dimestichezza con la programmazione a oggetti, potrebbe aiutarti immaginare il bottone come un oggetto e l’attributo onClick come un metodo della classe da cui il bottone è istanziato. La coppia elemento-evento a cui ci interessiamo corrisponde a una coppia oggetto-metodo.
[caption id="attachment_33080" align="aligncenter" width="1280"] the button was clicked![/caption]
Come abbiamo fatto più volte in precedenza, possiamo inserire il nostro event listener in una costante e iniettarla nella parte di JSX:
// File: src/components/Message.jsx
export default function Message({ message }) {
const onClick = () => {
console.log("The button was clicked!");
};
return (
<button className="Message" onClick={onClick}>
{message}
</button>
);
}
Possiamo notare come stiamo passando una funzione come attributo a un elemento HTML. Abbiamo accennato in precedenza a come in React sia perfettamente normale avere attributi che non sono soltanto stringhe, ma qualsiasi tipo di valore valido in JavaScript.
Ora che il nostro componente Message può intercettare il click del bottone, possiamo avvisare di questo evento anche App, utilizzando lo stesso metodo. Questa volta, però, dovremo aggiungere una prop:
// File: src/components/Message.jsx
export default function Message({ message, onButtonClick }) {
const onClick = () => {
onButtonClick();
};
return (
<button className="Message" onClick={onClick}>
{message}
</button>
);
}
Con il cambiamento soprastante, dichiariamo che il componente Message si aspetta una prop, onButtonClick, che altro non è che un event listener, esattamente come onClick dell’elemento HTML button. Quando il bottone viene cliccato, la funzione onClick lo “fa rimbalzare” al componente parente di Message. In altre parole, button avvisa Message e Message avvisa il suo componente genitore, che in questo caso è App:
// File: src/App.jsx
import "./App.css";
import Message from "./components/Message";
import Panel from "./components/Panel";
export default function App() {
const message = "Look, a message!";
const onButtonClick = () => {
console.log("The button was clicked!");
};
return (
<div className="App">
<Panel>
<Message message={message} onButtonClick={onButtonClick} />
</Panel>
</div>
);
}
Abbiamo spostato la gestione del click dal componente figlio al componente genitore.
Eventi in React VS eventi in Javascript
Il metodo con cui assegnamo event listener in React assomiglia molto a quello nativo JavaScript:
// Esempio di un event listener nativo JavaScript
const buttonElement = document.querySelector("button");
buttonElement.addEventListener("click", () => {
console.log("The button was clicked!");
});
Ci sono, però, un paio di differenze:
In JSX, avendo direttamente accesso all’elemento HTML, possiamo collegare il nostro event listener direttamente ad esso. Un metodo nativo molto simile è quello di utilizzare l’attributo onclick che, però, implica l’uso di una funzione globale (i.e.: legata a window) che è sempre una scommessa da un punto di vista di sicurezza (a seconda di cosa fa la funzione listener). Nel caso di React, la funzione listener è all’interno del componente (in uno scope isolato) per cui non abbiamo problemi di sicurezza. Come da prassi quando si tratta di React, utilizziamo una forma camelCase (onClick).
Così come in linguaggio JavaScript nativo, alla funzione listener viene passato un argomento, che rappresenta l’evento. A differenza di JavaScript nativo, però, l’evento passa attraverso React, che lo trasforma in un evento sintetico (nella versione TypeScript di React si parla proprio di SyntheticEvent). Questo è uguale a un evento nativo per certi aspetti (preventDefault e stopPropagation, per esempio, funzionano nello stesso modo), mentre porta alcune differenze, le più importanti delle quali saranno affrontate nelle prossime sezioni.
Passare una funzione VS chiamare una funzione
React riserva un posto speciale per le funzioni, come possiamo notare dal fatto che i componenti stessi, che sono alla base del suo funzionamento, sono funzioni. Le funzioni sono i verbi del programmatore e nello stesso modo in cui, nel linguaggio naturale, le cose possono diventare complesse, così è anche in programmazione informatica, soprattutto nella programmazione dichiarativa.
Parlando di eventi, allora, affrontiamo una delle differenze fondamentali e più difficili da afferrare per chi si affaccia al mondo della programmazione funzionale: quella tra il passaggio di una funzione e l’esecuzione di una funzione. Questa differenza ha generato così tanta confusione che Vue, un altro framework front end incredibilmente popolare, l’ha eliminata completamente.
Partiamo dalle cose semplici: immaginiamo di voler costruire un sistema che ci dice se una stringa è vuota o non vuota, che verrà collegato a un form e servirà a validare, per esempio, i campi “nome” e “cognome” durante la registrazione di un utente.
Cominciamo con una funzione che, data una stringa, restituisce true se la stringa non è vuota, false se è vuota. Possiamo chiamarla isNonEmptyString:
function isNonEmptyString(string) {
return typeof string === "string" && string !== "";
}
// Esempi di utilizzo
console.log(isNonEmptyString("Anything")); // true
console.log(isNonEmptyString("")); // false
Nota: dato che in questo caso le stringhe vuote sono un errore, ha senso restituire true per le stringhe non vuote e non il contrario. Inoltre, abbiamo aggiunto un controllo sul tipo così che qualsiasi cosa non sia una stringa venga trattata come una stringa vuota che, in questo caso, indica un errore.
Ora, per fare un validatore servono due cose: la funzione di validazione e il messaggio da mostrare in caso di errore. Potremmo pensare di creare una nuova funzione che usa isNonEmptyString in un modo simile a questo:
function getEmptyStringError(string, errorMessage) {
const validationResult = isNonEmptyString(string);
if (validationResult) {
return null;
} else {
return errorMessage;
}
}
// Esempi di utilizzo
const nonEmptyStringError = getEmptyStringError("Anything", "Error!");
const emptyStringError = getEmptyStringError("", "Error!");
console.log(nonEmptyStringError); // null
console.log(emptyStringError); // "Error!"
In caso di stringa vuota, getEmptyStringError restituirà il messaggio di errore, altrimenti restituirà null (i.e.: nessun errore). Passiamo il messaggio di errore come argomento per metterci in condizione di chiamare la funzione più volte con errori diversi per campi diversi. Per esempio, nome e cognome in un form avrebbero messaggi errori diversi:
const firstNameValidation = getEmptyStringError(
firstName,
"Your first name cannot be empty!"
);
const lastNameValidation = getEmptyStringError(
lastName,
"Your last name cannot be empty!"
);
Ora, decidiamo di fare la stessa cosa per validare la maggiore età di un utente, con una funzione che chiede gli anni d’età e verifica che sia almeno 18:
function isOfAge(ageInYears) {
return ageInYears >= 18;
}
function getNotOfAgeError(ageInYears, errorMessage) {
const validationResult = isOfAge(ageInYears);
if (validationResult) {
return null;
} else {
return errorMessage;
}
}
// Esempi di utilizzo
const ofAgeError = getNotOfAgeError(18, "Error!");
const notOfAgeError = getNotOfAgeError(17, "Error!");
console.log(ofAgeError); // null
console.log(notOfAgeError); // "Error!"
Noti qualcosa? Entrambe le funzioni hanno la stessa forma, che potremmo rappresentare così:
function getValidationError(input, errorMessage) {
const validationResult = validation(input);
if (validationResult) {
return null;
} else {
return errorMessage;
}
}
Con un occhio al futuro, immaginando che i validatori saranno molteplici, decidiamo di spezzare la logica. Per farlo, ci servono input, validation e errorMessage. Proviamo a creare una funzione che li prende tutti come argomenti:
function getValidationError(validation, input, errorMessage) {
const validationResult = validation(input);
if (validationResult) {
return null;
} else {
return errorMessage;
}
}
function isNonEmptyString(string) {
return typeof string === "string" && string !== "";
}
function isOfAge(ageInYears) {
return ageInYears >= 18;
}
Notiamo come validation è usato come una funzione (validation(input)). Per usare la nuova funzione getValidationError, dobbiamo trattare isOfAge e isNonEmptyString come valori e non come funzioni!
// Esempi d'uso
const nonEmptyStringError = getValidationError(
isNonEmptyString,
"Anything",
"Error!"
);
const emptyStringError = getValidationError(isNonEmptyString, "", "Error!");
const ofAgeError = getValidationError(isOfAge, 18, "Error!");
const notOfAgeError = getValidationError(isOfAge, 17, "Error!");
console.log(nonEmptyStringError); // null
console.log(emptyStringError); // "Error!"
console.log(ofAgeError); // null
console.log(notOfAgeError); // "Error!"
La differenza sostanziale sta tra:
// Prima
const validationResult = isNonEmptyString(string);
// ^^^^^^^^^^^^^^^^
// Dopo
const emptyStringError = getValidationError(isNonEmptyString, "", "Error!");
//
Nel primo caso, chiamiamo la funzione isNonEmptyString, nel secondo la passiamo. La differenza sintattica sono le parentesi dopo il nome della funzione: (string); la differenza concettuale è che nel secondo caso ci penserà la funzione getValidationError a chiamare isNonEmptyString.
6
Lo stato in React
6.1
Che cos'è lo stato di un componente in React
Lo stato di un componente in React è la rappresentazione del componente in un momento specifico del tempo, tipicamente adesso. “Adesso” inteso come il momento in cui si guarda lo stato.
Facciamo, come prima cosa, un esempio pratico; parleremo del concetto teorico nella prossima sezione. L’esempio in questione è il più classico degli esempi in React.
Immaginiamo di voler creare un contatore.
Vogliamo due bottoni, uno che dice più (+) e uno che dice meno (-). Il contatore inizia con valore zero. Il bottone che dice + aumenta di uno il valore del contatore, il bottone che dice - diminuisce di uno il valore del contatore. Il contatore non scende mai sotto zero. Lasciamo da parte, per un attimo, le props e gli eventi, inserendo l’intera logica nel componente contatore, mentre App non farà altro che ospitarlo.
Iniziamo creando un componente che mostra solo l’interfaccia.
Possiamo inserirlo nella cartella components e chiamarlo Counter. Creiamo anche un file Counter.css relativo. Possiamo tenere l’elemento Panel, ma dobbiamo ricordarci di rimuovere il controllo che non permette a Panel di avere componenti figli che non siano Message, altrimenti ci troveremo un errore.
// File: src/components/Counter.jsx
import "./Counter.css";
export default function Counter() {
return (
<div className="Counter">
<button>-</button>
<span>0</span>
<button>+</button>
</div>
);
}
/* File: src/components/Counter.css */
.Counter > * + * {
margin-left: 0.5em;
}
// File: src/App.jsx
import "./App.css";
import Counter from "./components/Counter";
import Panel from "./components/Panel";
export default function App() {
return (
<div className="App">
<Panel>
<Counter />
</Panel>
</div>
);
}
// File: arc/components/Panel.jsx
import "./Panel.css";
export default function Panel({ children }) {
return <div className="Panel">{children}</div>;
}
Ragioniamo, ora, sulla struttura del counter:
Il numero al centro rappresenta il valore del contatore “adesso”. È il nostro stato!
Il bottone che dice - deve togliere uno al valore del contatore, a meno che il valore non sia zero. In altre parole, se il valore del contatore è diverso da zero, lo diminuisce di uno.
Il bottone che dice + deve aggiungere uno al valore del contatore.
Ecco il codice che rappresenta queste funzionalità:
// File: src/components/Counter.jsx
import "./Counter.css";
import { useState } from "react";
export default function Counter() {
const [value, setValue] = useState(0);
const onMinusButtonClick = () => {
if (value !== 0) {
setValue((value) => {
return value - 1;
});
}
};
const onPlusButtonClick = () => {
setValue((value) => {
return value + 1;
});
};
return (
<div className="Counter">
<button onClick={onMinusButtonClick}>-</button>
<span>{value}</span>
<button onClick={onPlusButtonClick}>+</button>
</div>
);
}
Analizziamo il codice soprastante:
useState è la funzione importata da React che ci permette di creare uno stato. La funzione prende come argomento lo stato iniziale, in questo caso 0. Lo stato iniziale è il valore che ha lo stato nel momento in cui il componente viene creato. Il valore iniziale di useState può anche essere calcolato con una funzione, in questo caso passiamo una funzione callback a useState (useState(() => { /* qui calcoliamo lo stato */ })). Attenzione: quando usiamo una funzione per calcolare il valore iniziale dello stato, la funzione deve essere sincrona (i.e.: non possiamo usare async o una Promise). Per gestire stati asincroni, ci sono delle teniche che vedremo nelle prossime sezioni.
useState restituisce un array con due valori al suo interno. Il primo valore della coppia restituita da useState è il valore dello stato “adesso”. Il secondo valore della coppia restituita da useState è una funzione, che possiamo chiamare setter (“impostatrice”).
La funzione setter serve a impostare il valore che lo stato avrà “dopo”, il prossimo valore. La funzione setter non restituisce nulla. Inoltre, è bene precisare che la funzione setter è una funzione di ordine superiore e gli sviluppatori di React incoraggiano l'utilizzo di una callback (così come mostrato negli esempi precedenti per incrementare o decrementare un valore)
Usiamo la destruttrazione per estrarre i valori della coppia restituita da useState. Avremmo potuto scrivere una cosa come const valueState = useState(0);, e poi chiamare il valore valueState[0] e la funzione setter valueState[1]. Invece, per comodità, desctrutturiamo la coppia con const [value, setValue] = useState(0);.
I nomi che abbiamo dato ai valori della coppia restituita da useState sono più o meno arbitrari. È convenzionale, in React, chiamare la funzione setter con set seguito dal nome dello stato (per esempio [message, setMessage], [isVisible, setIsVisible]). Abbiamo scelto value perché lo stato rappresenta il valore del contatore. Essendo all’interno del componente Counter il fatto che il valore sia “del contatore” è implicito (per cui non abbiamo scelto, per esempio, counterValue).
Definiamo due event listener. All’interno, chiamiamo la funzione setter con il valore che lo stato dovrà avere “dopo”, vale a dire il prossimo valore. Nel caso di onMinusButtonClick, se il valore di “adesso” è diverso da zero (cioè maggiore o uguale a zero, perché - ricordiamo - il valore non scende mai sotto zero), allora il prossimo valore è il valore di “adesso”, meno uno. Nel caso di onPlusButtonClick, il prossimo valore è sempre il valore “attuale”, più uno.
Immutabilità dello stato di un componente in React e callback
Ci sono due cose estremamente importanti da sapere sugli stati di React:
Lo stato è immutabile. Questo vuol dire che il valore dello stato (in questo caso value) non può essere modificato direttamente: per modificarlo, utilizziamo la funzione setter. Che lo stato sia un valore primitivo - come una stringa o un numero - o che sia una struttura come un array o un oggetto, modificare direttamente il valore dello stato (per esempio con value = 42 o value++) non ha nessun effetto. Il perché lo scopriremo nelle prossime sezioni.
La funzione setter può essere chiamata in due modi: passando direttamente il prossimo valore dello stato, per esempio setValue(0), oppure, come nell’esempio soprastante, passando una funzione che prende come argomento il valore dello stato “adesso” e restituisce il prossimo valore.
Si utilizza la seconda forma quando il prossimo valore dello stato dipende da quello corrente, quando il valore corrente ci serve per calcolare il prossimo. Si utilizza la prima forma quando il valore corrente non è importante, quando il prossimo valore dello stato è costante.
Quando il calcolo del prossimo valore dello stato contiene il valore corrente, cioè quando il prossimo valore dello stato dipende da quello di “adesso”, è importante utilizzare la forma setState((currentStateValue) => { return nextStateValue; }); invece della forma setState(nextStateValue);.
Anche se il valore corrente dello stato potrebbe essere a nostra disposizione nel momento in cui chiamiamo la funzione setter, ci sono dei casi in cui questo valore potrebbe non essere aggiornato. Per evitare il problema, se il valore dello stato “adesso” ci serve per calcolare quello “dopo”, chiamiamo sempre la funzione setter passando una funzione.
6.2
Sollevare lo stato in React
Abbiamo finalmente chiuso il cerchio con props, eventi e stati. Prima di continuare a ragionare sul funzionamento di React, modifichiamo la nostra app per utilizzare tutti e tre i concetti.
L’operazione che stiamo per effettuare si chiama “sollevamento dello stato” ed è un’operazione molto comune nello sviluppo di applicazioni React.
Il concetto di base è che gli stati che vengono utilizzati da più componenti dovrebbero stare nel componente genitore che li contiene tutti, in modo che le informazioni possano viaggiare verso i figli senza essere duplicate. Una sorta di “minimo comun denominatore”, per cui il genitore comune più vicino contiene lo stato condiviso dai suoi discendenti. Per fare un esempio specifico del nostro caso, se il valore del contatore servisse non solo al componente Counter, ma ad altri componenti, non avrebbe senso creare lo stato nel componente Counter e cercare di tenerlo sincronizzato con App per passarlo ad altri componenti figli di App. Visto che le informazioni in React viaggiano solo dai genitori verso i figli, è bene che esse vivano nei componenti genitori.
Allo stesso modo, se il contatore non fosse aggiornato solo da Counter ma anche da altri componenti (per esempio, se App avesse un altro componente figlio che contiene un bottone per riportare il valore del contatore a zero), sarebbe di nuovo meglio tenere lo stato che rappresenta il valore del contatore all’interno di App e non all’interno di Counter.
Per effettuare questa operazione di sollevamento dello stato, possiamo sfruttare props ed eventi.
Possiamo descrivere la situazione corrente in questo modo: “il componente Counter contiene lo stato che rappresenta il suo valore. Quando i bottoni all’interno di Counter vengono cliccati, Counter aggiorna il suo stato”.
Possiamo, invece, descrivere la situazione che vogliamo ottenere in questo modo: "il componente App contiene lo stato che rappresenta il valore del contatore. Il componente App passa l’informazione che rappresenta il valore del contatore al componente Counter. Quando i bottoni all’interno di Counter vengono cliccati, Counter lancia un evento, che viene ascoltato da App e notifica App del nuovo valore del contatore. Quando App riceve un evento del genere, App aggiorna il suo stato interno. Questo perchè, se lasciassimo il cambio di stato all’interno del contatore e avessimo più di un counter, accadrebbe che ogni singolo counter verrebbe gestito all’unisono e non in maniera indipendente.
Cominciamo con la modifica di Counter:
import "./Counter.css";
export default function Counter({ value, onValueChange }) {
const onMinusButtonClick = () => {
if (value !== 0) {
onValueChange(value - 1);
}
};
const onPlusButtonClick = () => {
onValueChange(value + 1);
};
return (
<div className="Counter">
<button onClick={onMinusButtonClick}>-</button>
<span>{value}</span>
<button onClick={onPlusButtonClick}>+</button>
</div>
);
}
Come nel caso precedente, all’interno di Counter chiamiamo il valore del contatore value. Questa volta, però, ci aspettiamo di riceverlo come proprietà.
Non abbiamo più una funzione setter (setValue) perché, dal punto di vista di Counter, non importa cosa farà il suo componente genitore con il prossimo valore di value. Noi sappiamo che Counter riceverà una funzione onValueChange che corrisponde a una funzione setter, ma Counter non lo sa, né gli interessa. Il compito di Counter è solo quello di notificare che value ha un nuovo valore, e qual è il nuovo valore.
Passiamo ad App:
import { useState } from "react";
import "./App.css";
import Counter from "./components/Counter";
import Panel from "./components/Panel";
export default function App() {
const [counterValue, setCounterValue] = useState(0);
const onCounterValueChange = (newCounterValue) => {
setCounterValue(newCounterValue);
};
return (
<div className="App">
<Panel>
<Counter value={counterValue} onValueChange={onCounterValueChange} />
</Panel>
</div>
);
}
La cosa più importante da notare, qui, è la nomenclatura. Mentre all’interno di Counter parliamo di value e onValueChange, all’interno di App parliamo di counterValue, setCounterValue e onCounterValueChange. In App, value sarebbe un nome troppo generico: valore di cosa? Se App avesse cento componenti figli, nel momento in cui qualcuno dovesse leggere il nostro codice, dovrebbe poter capire di cosa stiamo parlando.
Il resto dovrebbe essere abbastanza auto-esplicativo, ma importante perché questo è il primo caso in cui usiamo props, eventi e stati tutti insieme. Il valore del contatore viene passato a Counter tramite props, il componente App viene notificato da Counter quando il valore del contatore cambia, e App mantiene lo stato del componente “adesso”.
Componenti view e componenti controller in React
Possiamo definire App un componente controller (“controllore”) e Counter un componente view (“vista”).
Counter è un componente view perché il suo compito è quello di visualizzare uno stato che riceve dall’esterno e notificare i cambiamenti di questo stato.
App è un componente controller perché il suo compito è di gestire uno stato che viene visualizzato da un altro componente.
La progettazione di componenti controller e componenti view in React è molto importante.
Da una parte, i componenti view sono più facilmente riutilizzabili, soprattutto quando non visualizzano un’informazione in particolare ma un tipo di informazione. Possiamo immaginare un componente view creato per rappresentare tutti gli utenti, tutti gli elementi di una lista, o tutti i numeri: finché la struttura dell’informazione resta uguale, dato che il componente view non gestisce uno stato ma lo visualizza soltanto e - a volte, non per forza - notifica i suoi cambiamenti, possiamo riutilizzarlo.
Dall’altra parte, i componenti controller sono il cervello della nostra applicazione, ciò che la rende effettivamente utile a qualcosa. Senza componenti controller, avremmo una bella interfaccia grafica che non combina niente.
Il segreto di un’applicazione React ben fatta è un equo bilanciamento di componenti view e componenti controller. Concentrare troppa logica in un unico componente controller rischia di rendere l’applicazione troppo complicata, ma frammentare la logica in un milione di piccoli componenti controller, diminuendo il numero di componenti view, rischia di rendere i componenti troppo specifici, quindi difficili da riutilizzare. Il resto di questa guida sarà concentrato sulla gestione di questo delicato equilibrio.
6.3
Componenti controllati e non controllati in React
Abbiamo appena parlato di componenti controller e componenti view. In React, i componenti controller vengono anche chiamati uncontrolled (non controllati) e i componenti view vengono anche chiamati controlled (controllati). Il collegamento potrebbe confondere un pochino all’inizio, ma dovrebbe aiutare pensare che, se un componente non viene controllato, allora sta controllando un altro componente.
Di conseguenza, un componente non controllato è un componente controller. Allo stesso modo, se un componente non sta controllando allora è controllato, per cui chiamiamo controllati i componenti view.
I componenti controller (non controllati) gestiscono gli stati, di cui inviano il valore ai componenti view (controllati). I componenti view (controllati) notificano i componenti controller (non controllati) dei cambiamenti dello stato.
Potrebbe capitarti di ricevere un avvertimento da React che dice:
Un componente sta cambiando un input non controllato per trasformarlo in un input controllato
In inglese:
A component is changing an uncontrolled input to be controlled
Prima di tutto, è importante citare che tutti gli input in React lavorano con le stringhe. Non importa se type è “number” o qualcos’altro che non c’entra niente con le stringhe. Perfino le checkbox usano stringhe, infatti leggiamo il loro attributo checked e non value.
Quando passiamo a input un attributo value che è null o undefined, React pensa che stiamo usando quell’elemento in modo non controllato (cioè come un componente controller). Potrebbe succedere perché ci dimentichiamo di inizializzare uno stato, oppure perché prendiamo un dato dall’esterno, per esempio dalla rete o da LocalStorage o SessionStorage, e ci dimentichiamo di inizializzarlo a stringa vuota. Se il valore che passiamo all’attributo value cambia e diventa una stringa, React si ritrova improvvisamente tra le mani un componente controllato, e lancia l’errore.
Possiamo portare questa regola nei nostri componenti, per essere sempre sicuri che i componenti controller e i componenti view siano sempre correttamente impostati:
Se un componente si aspetta una prop di un certo tipo, diamole un valore iniziale dello stesso tipo.
Quando passiamo una prop che deriva da uno stato a un componente figlio, passiamo sempre anche un event listener collegato alla funzione setter dello stesso stato.
7
Il ciclo di rendering in React
7.1
Come react gestisce props stati ed eventi
È arrivato il momento di dare un’occhiata dietro le quinte e capire come React gestisce i concetti di prop, stati ed eventi.
Sappiamo che i componenti React sono funzioni che restituiscono JSX. In che modo React li utilizza per mostrare la pagina web della nostra applicazione?
React esegue le funzioni che compongono i nostri componenti, trasforma JSX in JavaScript ed esegue il codice JavaScript risultante, che è tipicamente un ammasso di istruzioni document.createElement. In questo modo, il nostro codice JSX diventa codice HTML.
Per dimostrarlo, possiamo inserire un console.log all’interno di App e Counter:
// File: src/App.jsx
// ...
export default function App() {
console.log("App function was called!");
// ...
}
// File: src/components/Counter.jsx
// ...
export default function Counter({ value, onValueChange }) {
console.log("Counter function was called!");
// ...
}
[caption id="attachment_33154" align="aligncenter" width="800"] Log della console[/caption]
Ricaricando la pagina, possiamo vedere i log in console.
Nota: durante lo sviluppo, React chiama le funzioni-componente due volte. Questa cosa è normale e non succede quando l’applicazione viene pubblicata. Se noti che alcune cose succedono due volte (soprattutto le chiamate di rete, tipicamente, preoccupano) non ti preoccupare!
La catena di chiamate alle funzioni-componenti, in React, si chiama rendering. Quando un’applicazione React viene visualizzata, React effettua il primo rendering, trasformando JSX in JavaScript e poi in HTML e inserisce il codice HTML risultante nel <div id="app" /> che trovi nel file index.html. Le istruzioni per fare questa cosa sono nel file index.js (o index.jsx), che troverai in posti diversi a seconda della scelta che hai fatto tra Create React App e Vite. Ci sono, comunque, solo un file index.html e uno index.js (o index.jsx) in tutta l’applicazione, per cui non dovrebbe essere difficile trovarli:
import React from "react";
import ReactDOM from "react-dom/client";
import "./index.css";
import App from "./App";
import reportWebVitals from "./reportWebVitals";
const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);
7.2
I cicli di rendering in React
Dopo aver effettuato il primo rendering, alla prima visualizzazione della pagina, React si ferma. Fino a quando? Fino a quando non viene chiamata una funzione setter.
Se clicchi sul bottone che dice “+”, vedrai apparire due nuovi log in console: entrambe le funzioni-componenti, App e Counter, sono state eseguite di nuovo!
Importante: potresti leggere in articoli e documentazione, che React effettua un rendering ogni volta che uno stato cambia. Questa cosa non è sempre vera: per le strutture dati non primitive, come array e oggetti, anche se una funzione setter viene chiamata con un valore identico al precedente, il rendering viene eseguito comunque. Per esempio, se counterValue fosse { value: 5 } e chiamassimo setCounterValue({ value: 5 }), il rendering accadrebbe anche se lo stato non è tecnicamente cambiato. Per non sbagliare, ipotizza che qualsiasi chiamata a una funzione setter causi un nuovo rendering.
Quando viene chiamata la funzione setter setCounterValue, React esegue di nuovo la trasformazione da JSX a JavaScript a codice HTML. Questa volta, però, invece di eliminare l’intero contenuto di <div id="app" /> e rifare tutto da capo, React prende nota di cosa è cambiato tra la versione di “adesso” e quella risultante dal nuovo HTML, e modifica la pagina per applicare solo i cambiamenti. In questo caso, modificherà solo il contenuto dello <span> che si trova all’interno di Counter, quello che contiene {value}.
Quando React effettua rendering
React esegue il rendering di tutti i componenti visibili alla prima visualizzazione della pagina. Una volta finito il primo ciclo di rendering, quando viene chiamata una funzione setter, React esegue il rendering dei soli componenti che:
Hanno subito la modifica di almeno uno stato
Sono figli di un componente che è stato chiamato al rendering
Nel nostro caso, il componente App viene chiamato al rendering perché lo stato counterValue è cambiato, il componente Counter viene chiamato al rendering perché è figlio di App.
Quando progettiamo applicazioni React, in particolare la distinzione tra componenti controller e componenti view, è utile tenere a mente queste regole. Se tutta la logica della nostra applicazione fosse concentrata nel componente radice (quello che non ha genitori e contiene tutti i figli), allora ogni cambiamento causerebbe il rendering di tutti i componenti.
Nella programmazione front-end, le operazioni più lente sono quelle che modificano la pagina, e React ci aiuta molto modificando solo le cose che effettivamente sono cambiate dopo aver trasformato JSX in JavaScript e in HTML.
Noi, invece, possiamo aiutare React creando diversi componenti controller che gestiscono diversi stati, in modo da accorciare il percorso degli eventi che notificano i cambiamenti, diminuendo di conseguenza la quantità di componenti che vengono chiamati al rendering quando gli eventi causano cambiamenti di stato.
8
Gestione dell'interfaccia utente con React
8.1
Cos è il codice dichiarativo
Prima di addentrarci nei dettagli della gestione dell’interfaccia utente con React, è utile comprendere il concetto di codice dichiarativo.
React adotta un approccio dichiarativo per la costruzione delle interfacce utente. Nel codice dichiarativo, descrivi cosa vuoi che accada e non come dovrebbe accadere, il che è in contrasto con l'approccio imperativo che si concentra sui passi specifici per raggiungere un risultato. Questo permette di scrivere codice più leggibile e mantenibile, in quanto ci si concentra sul risultato finale piuttosto che sui dettagli dell'implementazione.
Più nello specifico, nel contesto di React:
Codice dichiarativo: descrive cosa l'interfaccia utente dovrebbe rappresentare in un dato stato. React aggiorna automaticamente l'interfaccia utente per riflettere questo stato. Per esempio, si descrive come dovrebbe apparire un componente in base ai suoi stati e proprietà.
Codice imperativo: si concentrerebbe, invece, su una serie di istruzioni passo-passo per manipolare direttamente il DOM e aggiornare l'interfaccia utente.
Questa distinzione tra approccio dichiarativo e imperativo è fondamentale per comprendere il funzionamento di React e come facilita lo sviluppo di interfacce utente complesse in modo più intuitivo e gestibile. Nei paragrafi successivi, esploreremo entrambi gli approcci alla gestione dell'interfaccia.
8.2
Gestione dell’interfaccia: metodo imperativo
L’approccio imperativo prevede l’analisi di quali step vanno fatti. La documentazione ufficiale di React propone questa metafora: immaginiamo di essere passeggeri di un auto e, senza dire a chi guida dove vogliamo andare, diamo indicazioni sulle svolte da fare, una alla volta. Chi guida non sa dove stiamo andando e le nostre probabilità di successo sono basate sul fatto che daremo tutte le indicazioni giuste al momento giusto.
Prendiamo ad esempio la gestione dell’interfaccia di un form, il perfetto esempio di come passare da un approccio imperativo a uno dichiarativo
Ecco cosa vogliamo ottenere:
Quando viene premuto il pulsante “Create”, il bottone, l’input di testo e la checkbox si disabilitano.
Quando la richiesta ha successo, il bottone, l’input di testo e la checkbox si abilitano.
Quando la richiesta va in errore, il bottone, l’input di testo e la checkbox si abilitano. L’errore viene visualizzato.
import { useState } from "react";
import "./TodoItemForm.css";
export default function TodoItemForm({ todoItem, onChange, onSubmit }) {
const [error, setError] = useState(null);
const [isSubmitButtonDisabled, setIsSubmitButtonDisabled] = useState(false);
const [isDescriptionInputDisabled, setIsDescriptionInputDisabled] =
useState(false);
const [isCheckboxDisabled, setIsCheckboxDisabled] = useState(false);
// ...
const onFormSubmit = (event) => {
event.preventDefault();
setError(null);
setIsSubmitButtonDisabled(true);
setIsDescriptionInputDisabled(true);
setIsCheckboxDisabled(true);
onSubmit().then(
(_todoItem) => {
setIsSubmitButtonDisabled(false);
setIsDescriptionInputDisabled(false);
setIsCheckboxDisabled(false);
},
(error) => {
setError(error.message);
setIsSubmitButtonDisabled(false);
setIsDescriptionInputDisabled(false);
setIsCheckboxDisabled(false);
}
);
};
return (
<form className="TodoItemForm" onSubmit={onFormSubmit}>
<input
type="checkbox"
checked={todoItem.isDone}
onChange={(event) => {
onIsDoneChange(event.currentTarget.checked);
}}
disabled={isCheckboxDisabled}
/>
<input
type="text"
placeholder="Description"
value={todoItem.description}
onInput={(event) => {
onDescriptionChange(event.currentTarget.value);
}}
disabled={isDescriptionInputDisabled}
/>
<input type="submit" value="Create" disabled={isSubmitButtonDisabled} />
{error ? <p style={{ color: "red" }}>{error}</p> : null}
</form>
);
}
Abbiamo creato quattro stati: uno per l’errore ed uno per ogni elemento che vogliamo venga disabilitato durante il salvataggio dei dati.
Nota: potresti aver notato l’uso di un trattino basso per _todoItem nel metodo then della Promise. Si tratta di una convenzione per indicare argomenti di funzioni che scegliamo deliberatamente di non utilizzare, ma che vogliamo dichiarare, di solito perché ci serve un argomento successivo (per esempio il secondo argomento) oppure, come in questo caso, perché non vogliamo rischiare di dimenticarci che a quella funzione viene passato un argomento!!
Se stai pensando qualcosa come “non avrebbe avuto più senso usare un solo stato, per esempio isLoading, che indichi che la richiesta è in fase di caricamento?”, allora stai già pensando in modo dichiarativo. È difficile fare esempi di programmazione imperativa in React, perché la libreria stessa spinge a un approccio dichiarativo, ma pensiamo a come una cosa del genere potrebbe essere implementata in linguaggio JavaScript:
e.preventDefault();
hide(errorMessage);
disable(checkbox);
disable(descriptionInput);
disable(submitButton);
submit().then(
() => {
enable(checkbox);
enable(descriptionInput);
enable(submitButton);
},
() => {
show(errorMessage);
enable(checkbox);
enable(descriptionInput);
enable(submitButton);
}
);
8.3
Gestione dell’interfaccia: metodo dichiarativo
Quando scegliamo un approccio dichiarativo, la prima domanda che ci poniamo è: cosa vogliamo rappresentare? Questa domanda rivela già una cosa importante: è vero, stiamo lavorando sull’aspetto del form, ma l’aspetto del form dipende direttamente dalla richiesta di rete che effettua il salvataggio dei dati. Ed è quello che vogliamo rappresentare: non il form, ma la richiesta di rete.
La seconda domanda che ci poniamo è: quali sono le informazioni che descrivono quello che vogliamo rappresentare? Possiamo descrivere la richiesta di rete in quattro casi:
Inattivo: la richiesta non è ancora avvenuta.
Caricamento: il salvataggio sta succedendo, ma non sappiamo ancora come andrà.
Successo: il salvataggio è avvenuto, e conosciamo il suo risultato.
Fallimento: il salvataggio è avvenuto e ha fallito. Conosciamo la ragione per cui ha fallito.
A questo punto, possiamo costruire una base su cui costruire la nostra descrizione:
const NetworkRequestType = {
idle: "idle",
loading: "loading",
success: "success",
failure: "failure",
};
function makeIdleNetworkRequest() {
return { type: NetworkRequestType.idle };
}
function makeLoadingNetworkRequest() {
return { type: NetworkRequestType.loading };
}
function makeSuccessfulNetworkRequest(response) {
return { type: NetworkRequestType.success, response };
}
function makeFailedNetworkRequest(error) {
return { type: NetworkRequestType.failure, error };
}
Abbiamo definito i quattro stati della richiesta, rappresentati con degli oggetti. In qualsiasi momento del tempo, una chiamata di rete sarà rappresentata da un oggetto. L’oggetto ha sempre una proprietà type che ci dice in quale dei quattro stati è la richiesta. L’oggetto ha una proprietà response, che contiene i dati della risposta, solo quando lo stato è “successo”. L’oggetto ha una proprietà error, che contiene la ragione del fallimento, solo quando lo stato è “fallimento”.
Abbiamo anche creato quattro funzioni, che ci aiutano a creare richieste di rete nei diversi stati. In aiuto abbiamo anche NetworkRequestType, un oggetto che contiene la mappa di tutti e soli gli stati che una richiesta di rete può assumere.
A questo punto, possiamo integrare tutto nel nostro componente:
// File: src/components/TodoItemForm.jsx
import { useState } from "react";
import "./TodoItemForm.css";
const NetworkRequestType = {
idle: "idle",
loading: "loading",
success: "success",
failure: "failure",
};
function makeIdleNetworkRequest() {
return { type: NetworkRequestType.idle };
}
function makeLoadingNetworkRequest() {
return { type: NetworkRequestType.loading };
}
function makeSuccessfulNetworkRequest(response) {
return { type: NetworkRequestType.success, response };
}
function makeFailedNetworkRequest(error) {
return { type: NetworkRequestType.failure, error };
}
export default function TodoItemForm({ todoItem, onChange, onSubmit }) {
const [networkState, setNetworkState] = useState(makeIdleNetworkRequest());
// ...
const onFormSubmit = (event) => {
event.preventDefault();
setNetworkState(makeLoadingNetworkRequest());
onSubmit().then(
(todoItem) => {
setNetworkState(makeSuccessfulNetworkRequest(todoItem));
},
(error) => {
setNetworkState(makeFailedNetworkRequest(error.message));
}
);
};
const isLoading = networkState.type === NetworkRequestType.loading;
return (
<form className="TodoItemForm" onSubmit={onFormSubmit}>
<input
type="checkbox"
checked={todoItem.isDone}
onChange={(event) => {
onIsDoneChange(event.currentTarget.checked);
}}
disabled={isLoading}
/>
<input
type="text"
placeholder="Description"
value={todoItem.description}
onInput={(event) => {
onDescriptionChange(event.currentTarget.value);
}}
disabled={isLoading}
/>
<input type="submit" value="Create" disabled={isLoading} />
{networkState.type === NetworkRequestType.failure ? (
<p style={{ color: "red" }}>{networkState.error}</p>
) : null}
</form>
);
}
Abbiamo creato uno stato per rappresentare la richiesta di rete che effettua il salvataggio dei dati. Lo stato è inizializzato nello stato “inattivo”. Al click del bottone “Create”, lo stato passa a “caricamento”. A seconda dell’andamento della richiesta, lo stato passa a “successo” o “errore”.
Le funzioni che abbiamo creato per aiutarci coincidono perfettamente con i dati che abbiamo a disposizione quando lo stato cambia: passiamo todoItem in caso di successo ed error.message in caso di fallimento.
Abbiamo creato una costante, isLoading, che rappresenta il fatto che la richiesta sia in stato di “caricamento”. In caso di fallimento, estraiamo l’errore dallo stato e lo visualizziamo.
Questo esempio dovrebbe averti chiarito la differenza tra approccio imperativo e approccio dichiarativo nella gestione delle interfacce utente in React. Pronto per i prossimi capitoli?
9
Array e oggetti negli stati in React
9.1
Array e oggetti negli stati in React
Sappiamo che gli stati in React sono immutabili, cioè non possono essere modificati direttamente, ma dobbiamo chiamare la funzione setter per impostare il prossimo valore dello stato. L’impossibilità di modificare direttamente gli stati potrebbe, però, mettere in difficoltà chi è abituato a utilizzare linguaggi di programmazione che supportano lo stile imperativo (primo tra tutti, il linguaggio JavaScript stesso). Prendiamo ad esempio un’operazione del genere:
const onDescriptionChange = (description) => {
setTodoItem((todoItem) => {
// Non funziona!
todoItem.description = description;
return todoItem;
});
};
per quanto perfettamente lecita in JavaScript, non funzionerà! L’interfaccia non si aggiornerà e non raggiungeremo il risultato che vogliamo ottenere. Invece, per aggiornare lo stato, dobbiamo prima clonarlo e poi impostare le nuove informazioni:
const onDescriptionChange = (description) => {
setTodoItem((todoItem) => {
return {
...todoItem,
description,
};
});
};
La forma { ...todoItem } crea un nuovo oggetto e copia le proprietà dell’oggetto originale (todoItem) all’interno di esso, senza modificare l’oggetto originale (todoItem). Una volta creato il nuovo oggetto, associamo il valore del nostro argomento description alla proprietà “description” del nuovo oggetto. Potremmo scrivere la stessa senza usare lo spread operator (...) né la sintassi ridotta:
const onDescriptionChange = (description) => {
setTodoItem((todoItem) => {
return {
isDone: todoItem.isDone,
description: description,
};
});
};
Immaginiamo un caso come:
return {
firstName: user.firstName,
lastName: user.lastName,
fiscalCode: user.fiscalCode,
email: user.email,
addressCountry: user.addressCountry,
addressProvince: user.addressProvince,
addressCity: user.addressCity,
addressStreet: user.addressStreet,
addressStreetNumber: user.addressStreetNumber,
};
Se dovessimo scrivere tutto questo per ogni event listener, diventeremmo pazzi! E se in futuro aggiungessimo nuove proprietà all’oggetto user, dovremmo andare a ritoccare ogni event listener per aggiungere la nuova proprietà. Usiamo lo spread operator (...) perché, indipendentemente dal numero di proprietà che il nostro oggetto contiene, basta una riga di codice per crearne una copia.
Fun fact: lo spread operator (...) è stato introdotto per gli array con il rilascio di ES6 (EcmaScript 6, standard rilasciato nel 2015). Solo successivamente è stato possibile applicarlo anche agli oggetti, proprio grazie a React, che introdusse questa possibilità molto prima che diventasse lo standard.
Anche quando si tratta di array, c’è una serie di cose che non possono essere fatte nel contesto delle funzioni setter degli stati di React, in quanto modificherebbero l’array originale. E siamo pronti a scommettere che queste cose includono tutti i metodi che la maggior parte degli sviluppatori junior usano normalmente: push, pull, shift, unshift e splice.
Per ogni caso d’uso, c’è una soluzione che rispetta la regola dell’immutabilità. Vediamole tutte.
Aggiungere proprietà a un oggetto in React
// Versione con mutabilità
myObject.newKey = newValue;
// Versione senza mutabilità
const myNewObject = {
...myObject,
newKey: newValue,
};
Modificare proprietà di un oggetto in React
// Versione con mutabilità
myObject.oldKey = newValue;
// Versione senza mutabilità
const myNewObject = {
...myObject,
oldKey: newValue,
};
Rimuovere proprietà da un oggetto in React
// Versione con mutabilità
delete myObject.oldKey;
// Versione senza mutabilità
const { oldKey, ...myNewObject } = myObject;
Utilizziamo la destrutturazione per estrarre la proprietà che non ci serve più. Tutto ciò che rimane (myNewObject) è ciò che ci serve.
Aggiungere elementi a un array in React
// Versione con mutabilità
myArray.push(newFirstElement);
myArray.unshift(newLastElement);
// Versione senza mutabilità
const myNewArray = [firstNewElement, ...myArray, lastNewElement];
push e unshift possono essere effettuate da sole o in contemporanea nella versione immutabile.
Modificare elementi di un array per indice in React
// Versione con mutabilità
myArray[targetIndex] = newValue;
// Versione senza mutabilità
const newArray = myArray.map((oldValue, index) => {
if (index === targetIndex) {
return newValue;
} else {
return oldValue;
}
});
In questo caso, la versione immutabile è un po’ più complessa: dobbiamo scorrere l’intero array (map crea internamente una copia dell’array), riutilizzando tutti gli elementi dell’array precedente tranne quello che ci serve, che individuiamo per indice.
Modificare elementi di un array per valore in React
// Versione con mutabilità
const targetIndex = myArray.indexOf(oldValue);
if (targetIndex !== -1) {
myArray[targetIndex] = newValue;
}
// Versione senza mutabilità
const newArray = myArray.map((value) => {
if (value === oldValue) {
return newValue;
} else {
return value;
}
});
Anche in questo caso, dobbiamo scorrere l’intero array (map crea internamente una copia dell’array), riutilizzando tutti gli elementi dell’array precedente tranne quello che ci serve, che individuiamo per valore.
Rimuovere elementi da un array
// Versione con mutabilità
myArray.pop();
myArray.shift();
// Versione senza mutabilità
const [firstOldElement, ...myNewArray, lastOldElement] = myArray;
Come nel caso della rimozione di proprietà da oggetti, sfruttiamo la destrutturazione per ottenere tutto ciò che non è quello che non ci serve, cioè quello che ci serve (i maestri di algebra booleana direbbero che stiamo usando una tautologia: !!useful === useful).
9.2
Stati con liste in React
Pronto a mettere le mani in pasta? Mettiamo in pratica quello che abbiamo appena imparato!
Modifichiamo la nostra applicazione per mantenere uno stato della lista di cose da fare. Alla creazione di un nuovo elemento, dopo aver mandato la nostra finta richiesta di rete, aggiungiamo il nuovo elemento in cima alla lista.
Alla fine di questa modifica, la nostra applicazione avrà due stati: lo stato della lista di cose da fare, che possiamo chiamare todoList e lo stato dell’elemento che sta venendo creato, che coincide con lo stato todoItem che abbiamo in questo momento.
Ricordi quando dicevamo che avere lo stato todoItem all’interno di App poteva non essere la soluzione migliore? Bene, questo è il momento di riportarlo in TodoItemForm. Lo stato todoItem è legato solamente all’aspetto del form (in altre parole, l’aspetto del form dipende dallo stato todoItem), mentre App non si occupa di quella parte del sistema. App avrà invece il compito di occuparsi della lista intera, cioè il nuovo stato todoList.
// File: src/components/TodoItemForm.jsx
import { createTodoItem } from "../mock/createTodoItem";
import { useState } from "react";
import "./TodoItemForm.css";
/* ... */
export default function TodoItemForm({ onSubmit }) {
const [todoItem, setTodoItem] = useState({
description: "",
isDone: false,
});
const [networkState, setNetworkState] = useState(makeIdleNetworkRequest());
const onDescriptionChange = (description) => {
setTodoItem((todoItem) => {
return {
...todoItem,
description,
};
});
};
const onIsDoneChange = (isDone) => {
setTodoItem((todoItem) => {
return {
...todoItem,
isDone,
};
});
};
const onFormSubmit = (event) => {
event.preventDefault();
setNetworkState(makeLoadingNetworkRequest());
createTodoItem(todoItem).then(
(todoItem) => {
setNetworkState(makeSuccessfulNetworkRequest(todoItem));
onSubmit(todoItem);
setTodoItem({ description: "", isDone: false });
},
(error) => {
setNetworkState(makeFailedNetworkRequest(error.message));
}
);
};
const isLoading = networkState.type === NetworkRequestType.loading;
return (
<form className="TodoItemForm" onSubmit={onFormSubmit}>
<input
type="checkbox"
checked={todoItem.isDone}
onChange={(event) => {
onIsDoneChange(event.currentTarget.checked);
}}
disabled={isLoading}
/>
<input
type="text"
placeholder="Description"
value={todoItem.description}
onInput={(event) => {
onDescriptionChange(event.currentTarget.value);
}}
disabled={isLoading}
/>
<input type="submit" value="Create" disabled={isLoading} />
{networkState.type === NetworkRequestType.failure ? (
<p style={{ color: "red" }}>{networkState.error}</p>
) : null}
</form>
);
}
Abbiamo riportato lo stato todoItem all’interno di TodoItemForm. Al submit, inviamo la richiesta di rete per il salvataggio dei dati direttamente da qui. Se il salvataggio avviene correttamente, allora notifichiamo App dell’avvenuta creazione dell’elemento, passando alla prop onSubmit i relativi dati.
Con questo cambiamento, il componente App non avrà altre interazioni con TodoItemForm se non nel caso in cui un nuovo elemento è stato creato. Tutta la fase di creazione dell’elemento è gestita internamente dal componente TodoItemForm, creando una separazione dei compiti ben definita.
// File: src/App.jsx
import { useState } from "react";
import "./App.css";
import Panel from "./components/Panel";
import TodoItemForm from "./components/TodoItemForm";
export default function App() {
const [todoList, setTodoList] = useState([]);
const onTodoItemFormSubmit = (todoItem) => {
return setTodoList((todoList) => [todoItem, ...todoList]);
};
return (
<div className="App">
<Panel>
<TodoItemForm onSubmit={onTodoItemFormSubmit} />
</Panel>
</div>
);
}
La quantità di logica gestita dal componente App è diminuita sostanzialmente e si vede dalle dimensioni del componente. Ora App ha due compiti: mantenere lo stato della lista di cose da fare e aggiungere nuovi elementi che arrivano da TodoItemForm, sapendo che tutta la fase di validazione e salvataggio è gestita internamente dal componente figlio.
Se hai ESLint acceso e/o stai usando Create React App, noterai un avvertimento: non stiamo utilizzando todoList! Per adesso, questa cosa non ha importanza ma l’avvertimento ci aiuterà a tenerlo presente.
Le liste in JSX
Quando abbiamo uno stato che contiene un array, in React, utilizziamo tipicamente il metodo map di Array per trasformarlo in un array di elementi JSX.
// File: src/App.tsx
// ...
export default function App() {
const [todoList, setTodoList] = useState([]);
// ...
return (
<div className="App">
<div className="TodoList">
{todoList.map((todoItem, index) => {
return (
<div className="TodoItem" key={index}>
<input type="checkbox" value={todoItem.isDone} disabled />
<span>{todoItem.description}</span>
</div>
);
})}
</div>
{/* ... */}
</div>
);
}
Spoiler alert: nei prossimi paragrafi parleremo un sacco di componenti, elementi, proprietà e attributi. Per fare un rapidissimo ripasso: in JSX, parliamo di elementi e attributi in riferimento agli elementi HTML, riconoscibili per la prima lettera minuscola. Parliamo, invece, di componenti e proprietà (o props) in riferimento ai componenti di React, riconoscibili dalla prima lettera maiuscola. Quando si parla di JSX in generale, parlare di componenti o elementi è equivalente, perché JSX contiene un misto di entrambi.
Abbiamo trasformato una lista di elementi di cose da fare, con le loro proprietà description e isDone, in una lista di elementi JSX. Possiamo notare un paio di cose:
Per ogni concetto della nostra app, c’è un elemento HTML: l’intera app è circondata da un div, la lista è rappresentata da un div, c’è un div per ogni elemento della lista e un elemento HTML per ogni proprietà dell’elemento della lista (span per description, input[type=checkbox] per isDone). Creare una correlazione 1:1 tra parti della struttura dati ed elementi HTML può essere incredibilmente utile per mantenere un alto livello di confidenza sulla qualità del codice.
L’elemento div.TodoItem ha un attributo/proprietà key. key è una delle tre proprietà sempre valide in React (abbiamo visto children in precedenza) e definisce l’unicità di un elemento o componente in una lista. Merita una sezione a sé. Approfondiamola nel prossimo capitolo!
9.3
La proprietà Key in React
Come sappiamo, React cerca sempre di ottimizzare le performance di una applicazione e lo fa modificando solo le parti di codice HTML che cambiano tra un ciclo di rendering e l’altro. key ci permette di prendere il controllo di questo meccanismo, dichiarando a React quale stato o parte di stato coincide con quale elemento o componente.
La possibilità di controllare “manualmente” il meccanismo con cui React capisce quale elemento o componente corrisponde a quale informazione è utile in una serie di casi. Nei casi delle liste, invece, è obbligatorio: React non ha modo di distinguere l’uno dall’altro componenti o elementi che sono allo stesso livello, di conseguenza chiede a noi di farlo, dando un valore univoco a ogni key di ogni elemento o componente.
Con questo nuovo concetto in mente, potremmo renderci conto che index non è un buon valore da dare a key. Sì, index è univoco per ogni elemento della lista, ma quando aggiungiamo nuovi elementi allo stato, tutti gli indici cambiano.
Per esempio, quando aggiungiamo il primo elemento, che possiamo chiamare A, lo stato diventa [A] e l’elemento A ha indice 0 nella lista. Siccome stiamo inserendo i nuovi elementi in cima alla lista, non in fondo, all’aggiunta di un secondo elemento B, lo stato diventa [B, A]. Ora l’indice di B è 0 e l’indice di A è 1. L’indice di A è cambiato, da 0 a 1, ma l’elemento è rimasto identico.
Quando un elemento o componente cambia key, React ne sostituisce completamente l’elemento HTML corrispondente, anche se tutti i suoi attributi o proprietà e contenuto non sono cambiati. Sappiamo che le operazioni sui componenti HTML sono le più onerose da un punto di vista di performance: mantenendo key={index}, stiamo attivamente peggiorando le performance della nostra applicazione!
Ci sono vari modi per risolvere questo problema. La maggior parte delle volte, quando comunichiamo con un back-end, ogni elemento di ogni lista ha qualche tipo di ID univoco, che deriva da una chiave primaria di un database: usare quello, se è disponibile, è l’opzione migliore nel 99% dei casi.
Nel nostro caso, potremmo decidere di assegnare una data di creazione ad ogni elemento, che sarebbe univoca perché il linguaggio JavaScript misura il tempo in millisecondi (creare due elementi nello stesso millisecondo è virtualmente impossibile). Oppure, sapendo che, nel caso specifico, aggiungiamo i nuovi elementi all’inizio dell’array, possiamo invertire gli indici (i.e.: utilizzare todoList.length - index invece di index).
// File: src/App.jsx
import { useState } from "react";
import "./App.css";
import Panel from "./components/Panel";
import TodoItemForm from "./components/TodoItemForm";
export default function App() {
const [todoList, setTodoList] = useState([]);
const onTodoItemFormSubmit = (todoItem) => {
return setTodoList((todoList) => [todoItem, ...todoList]);
};
return (
<div className="App">
<div className="TodoList">
{todoList.map((todoItem, index, todoList) => {
return (
<div className="TodoItem" key={todoList.length - index}>
<input type="checkbox" value={todoItem.isDone} disabled />
<span>{todoItem.description}</span>
</div>
);
})}
</div>
<Panel>
<TodoItemForm onSubmit={onTodoItemFormSubmit} />
</Panel>
</div>
);
}
Ora il primo elemento A ha indice 1, perché [A] ha lunghezza 1 e A ha indice zero e 1 - 0 === 1. All’arrivo del secondo elemento B, [B, A] ha lunghezza 2, B ha indice 2 - 0 === 2 e A mantiene il suo indice 1, calcolato con 2 - 1 === 1.
9.4
Economia dei componenti in React
In questo momento, la nostra applicazione ha due problemi:
Troppo codice nel componente App.
È davvero bruttina da vedere; non che il nostro CSS sia da premio, ci stiamo concentrando sui concetti di React, ma così siamo proprio in preda all’aspetto di default del browser.
Come possiamo risolvere?
Ci sono due elementi div, rispettivamente con classe TodoList e TodoItem, che sembrano proprio chiedere di essere isolati in componenti! Facciamolo: creiamo due nuovi componenti, in src/components/TodoList e src/components/TodoItem, con i loro file CSS relativi.
/* File: src/components/TodoItem.css */
.TodoItem {
display: flex;
align-items: baseline;
}
.TodoItem input[type="checkbox"] {
margin: 0 0.5em 0 0;
}
// File: src/components/TodoItem.jsx
import "./TodoItem.css";
export default function TodoItem({ todoItem }) {
return (
<div className="TodoItem">
<input type="checkbox" checked={todoItem.isDone} disabled />
<span>{todoItem.description}</span>
</div>
);
}
/* File: src/components/TodoList.css */
.TodoList {
width: 90vw;
max-width: 45em;
margin: 1.5em auto;
}
// File: src/components/TodoList.jsx
import TodoItem from "./TodoItem";
import "./TodoList.css";
export default function TodoList({ todoList }) {
return (
<div className="TodoList">
{todoList.map((todoItem, index, todoList) => {
return <TodoItem key={todoList.length - index} todoItem={todoItem} />;
})}
</div>
);
}
// File: src/App.jsx
import { useState } from "react";
import "./App.css";
import Panel from "./components/Panel";
import TodoItemForm from "./components/TodoItemForm";
import TodoList from "./components/TodoList";
export default function App() {
const [todoList, setTodoList] = useState([]);
const onTodoItemFormSubmit = (todoItem) => {
return setTodoList((todoList) => [todoItem, ...todoList]);
};
return (
<div className="App">
<TodoList todoList={todoList} />
<Panel>
<TodoItemForm onSubmit={onTodoItemFormSubmit} />
</Panel>
</div>
);
}
Molto meglio! Adesso abbiamo un minimo di allineamento.
Notiamo come l’attributo key non è stato portato dentro il componente TodoItem ma è rimasto in TodoList. Come abbiamo detto precedentemente, key non ha a che fare con la configurazione degli elementi o componenti, ma con la gestione di liste di elementi o componenti sullo stesso livello. Inoltre, dato che key è sempre valido sia come attributo che come proprietà, potevamo assegnarlo all’elemento div come possiamo assegnarlo ora al componente TodoItem e, anche se non è considerato all’interno del nostro componente, viene gestito direttamente da React.
In ottica di componenti controller/view, abbiamo creato dei nuovi componenti view, lasciando lo stato nel componente App, che controlla il nuovo componente TodoList e, di conseguenza, (visto che TodoList non è un componente controller) anche TodoItem.
Quando isolare un componente?
Approfittiamo di questo momento per ragionare sulla domanda: quando ha senso isolare un componente?
La risposta, forse un po’ vaga, è “ogni volta che cambiamo argomento”. Con questo intendiamo che, quando ci troviamo per le mani un nuovo “pezzo” di struttura dati, possiamo decidere di assegnarne la responsabilità a un nuovo componente. La distinzione tra un argomento e un altro è chiaramente soggetta alla sensibilità di chi sta scrivendo il codice: ecco perché la risposta è così vaga!
In questo caso, a nostro parere, gli argomenti sono: l’intera applicazione, la lista delle cose da fare e il singolo elemento della lista.
Il fatto di avere ottenuto sintassi come <TodoList todoList={todoList} /> e <TodoItem todoItem={todoItem} /> dovrebbe rassicurarci sul fatto che stiamo andando nella direzione giusta.
Quello di cui abbiamo appena parlato si applica sia da un punto di vista di dati, che da un punto di vista grafico: ogni elemento dell’interfaccia grafica dovrebbe sempre riflettere un pezzo di informazione, come i tag visualizzati come etichette, o le operazioni possibili visualizzate come bottoni. Questo dipende, però, anche da chi progetta le interfacce grafiche e non sempre la relazione è così univoca.
Un’attenta progettazione degli stati, delle prop e degli eventi, così come l’attenzione all’equilibrio tra componenti controller e componenti view, dovrebbero fare la maggior parte del lavoro. La quantità di componenti che ne risulta dovrebbe essere già equilibrata. Se dovessi trovarti a lavorare con componenti molto grandi (i.e.: che contengono molto codice) i casi sono due: o staresti effettivamente descrivendo cose molto complesse, oppure parte della logica potrebbe essere spostata in componenti figli o dentro funzioni hook.
Cos’è una funzione hook? Scopriamolo nella prossima sezione!
10
Hooks in React
10.1
Cos'è una funzione Hook in React
Una funzione hook (in italiano “gancio”, “amo”) è un componente senza interfaccia. useState è una funzione hook.
Ma se i componenti sono funzioni che restituiscono JSX e una funzione hook è un componente senza interfaccia, allora una funzione hook non restituisce JSX: questo la rende una funzione che non restituisce JSX ..cioè una normale funzione? Sì.
Tutte le funzioni hook sono funzioni, ma non tutte le funzioni sono funzioni hook. Che differenza c’è quindi tra una funzione hook e una normale funzione? Che una funzione hook può, al suo interno, utilizzare altre funzioni hook.
Se useState non fosse una funzione hook, questa cosa non avrebbe nessuna importanza. Il fatto che useState sia una funzione hook cambia tutto, perché useState è sincronizzata con i cicli di rendering di React. Ricordiamoci che React scatena un nuovo ciclo di rendering quando viene chiamata la funzione setter di uno stato.
Quando si usano le funzioni hook?
Le funzioni hook si usano quando abbiamo a che fare con informazioni che non vogliamo rappresentare in termini di JSX, o quando non possiamo farlo. Per avere infiniti esempi, puoi continuare a seguire questa guida, oppure spulciare questa raccolta di hooks.
L’unica regola per definire una funzione hook è che il nome della funzione deve cominciare con use (per esempio, appunto, useState).
10.2
Esempi di hooks: richieste di rete di tipo comando
Nelle sezioni precedenti, abbiamo gestito in modo dichiarativo la richiesta di rete per creare un elemento della lista di cose da fare:
// File: src/components/TodoItemForm.jsx
// ...
const NetworkRequestType = {
idle: "idle",
loading: "loading",
success: "success",
failure: "failure",
};
function makeIdleNetworkRequest() {
return { type: NetworkRequestType.idle };
}
function makeLoadingNetworkRequest() {
return { type: NetworkRequestType.loading };
}
function makeSuccessfulNetworkRequest(response) {
return { type: NetworkRequestType.success, response };
}
function makeFailedNetworkRequest(error) {
return { type: NetworkRequestType.failure, error };
}
export default function TodoItemForm({ onSubmit }) {
// ...
const [networkState, setNetworkState] = useState(makeIdleNetworkRequest());
// ...
const onFormSubmit = (event) => {
event.preventDefault();
setNetworkState(makeLoadingNetworkRequest());
createTodoItem(todoItem).then(
(todoItem) => {
setNetworkState(makeSuccessfulNetworkRequest(todoItem));
onSubmit(todoItem);
setTodoItem({ description: "", isDone: false });
},
(error) => {
setNetworkState(makeFailedNetworkRequest(error.message));
}
);
};
// ...
}
Questo tipo di funzionamento non vale solo per la richiesta di rete che crea l’elemento della lista di cose da fare, ma per tutte le richieste di rete di tipo “comando”, cioè quelle richieste che partono all’occorrenza, come al click di un bottone (a differenza, per esempio, di una richiesta per ottenere gli elementi già esistenti, che partirebbe nel momento in cui viene visualizzata la pagina).
Il concetto di richiesta di rete di tipo “comando” è un ottimo argomento per una funzione hook. Possiamo creare una funzione hook, che potremmo chiamare useCommand. - ricordiamoci che le funzioni hook sono funzioni che hanno un nome che inizia per use e che possono, al loro interno, fare uso di altre funzioni hook, come useState - .
Avendo a che fare con una funzione, dobbiamo prima di tutto decidere quali sono gli argomenti che riceve (il suo input) e qual è il risultato che restituisce (il suo output).
Per quanto riguarda l’output, ci servono sicuramente lo stato della richiesta di rete (idle, loading, success o failure) e una funzione da chiamare per far partire la richiesta, per esempio al click del bottone che dice “submit”.
La funzione per far partire la richiesta prenderà come argomenti i dati dell’elemento della lista, che abbiamo a disposizione in un momento del tempo diverso. Come nell’esempio dei validatori del form, ci sono alcuni dati che abbiamo a disposizione nel momento in cui scriviamo il codice, come l’URL a cui inviare la richiesta o il metodo HTTP che vogliamo usare. Altri dati invece - come la descrizione dell’elemento - li avremo a disposizione nel momento in cui l’utente li inserirà nel form.
Non avendo a disposizione un back-end, modifichiamo la funzione che finge di inviare una richiesta di rete per accettare un percorso e un metodo HTTP, come se fosse la funzione fetch del browser:
// File: src/mock/sendNetworkRequest.js
export function sendNetworkRequest({ path, method, data, shouldFail = false }) {
console.log(`Would send a ${method} request to ${path}`);
return new Promise((resolve, reject) => {
window.setTimeout(() => {
if (shouldFail) {
reject(new Error("Filed!"));
} else {
resolve(data);
}
}, 1000);
});
}
In un caso realistico, l’URL del server a cui inviamo la richiesta sarebbe salvato in un file env, che conterrebbe le variabili d’ambiente. La funzione per inviare le richieste accetterebbe come argomento, come nel nostro caso, il percorso (path) per completare l’URL. Ad esempio, la variabile d’ambiente potrebbe contenere il valore https://my-api.com e il path potrebbe essere /todo, così che la richiesta venga inviata a https://my-api.com/todo.
Per informazioni dettagliate sulle variabili d’ambiente in React, puoi fare riferimento alla guida ufficiale.
Ora che possiamo fingere di mandare una richiesta di rete, dividiamo in due fasi l’invio perché, come abbiamo detto, path e method sono disponibili sempre, mentre data sarà disponibile in futuro. Creiamo una nuova cartella hooks per raccogliere le nostre funzioni hook, in cui possiamo creare un nuovo file useCommand.js:
// File: src/hooks/useCommand.js
import { useState } from "react";
import { sendNetworkRequest } from "../mock/sendNetworkRequest";
export const NetworkRequestType = {
idle: "idle",
loading: "loading",
success: "success",
failure: "failure",
};
function makeIdleNetworkRequest() {
return { type: NetworkRequestType.idle };
}
function makeLoadingNetworkRequest() {
return { type: NetworkRequestType.loading };
}
function makeSuccessfulNetworkRequest(response) {
return { type: NetworkRequestType.success, response };
}
function makeFailedNetworkRequest(error) {
return { type: NetworkRequestType.failure, error };
}
export function useCommand({ path, method }) {
const [networkRequest, setNetworkRequest] = useState(
makeIdleNetworkRequest()
);
const sendCommand = (data) => {
setNetworkRequest(makeLoadingNetworkRequest());
return sendNetworkRequest({ path, method, data }).then(
(response) => {
setNetworkRequest(makeSuccessfulNetworkRequest(response));
return Promise.resolve(response);
},
(error) => {
setNetworkRequest(makeFailedNetworkRequest(error));
return Promise.reject(error);
}
);
};
return [networkRequest, sendCommand];
}
Rispetto agli esempi precedenti, l’unica novità è la funzione useCommand, il resto è stato solo spostato da TodoItemForm (perché stiamo rendendo generica la gestione di una richiesta di rete di tipo “comando”).
Prima di capire perché useCommand è conveniente, utilizziamolo all’interno del componente TodoItemForm:
// File: src/components/TodoItemForm.jsx
import { useState } from "react";
import "./TodoItemForm.css";
import { NetworkRequestType, useCommand } from "../hooks/useCommand";
export default function TodoItemForm({ onSubmit }) {
const [todoItem, setTodoItem] = useState({
description: "",
isDone: false,
});
const [createTodoItemNetworkRequest, createTodoItem] = useCommand({
path: "/todo",
method: "POST",
});
// onDescriptionChange e onIsDoneChange restano uguali
const onFormSubmit = (event) => {
event.preventDefault();
createTodoItem(todoItem).then((response) => {
onSubmit(response);
setTodoItem({ description: "", isDone: false });
});
};
const isLoading =
createTodoItemNetworkRequest.type === NetworkRequestType.loading;
return (
<form className="TodoItemForm" onSubmit={onFormSubmit}>
{/* anche il form rimane uguale */}
</form>
);
}
Abbiamo spostato tutta la gestione della richiesta di rete all’interno di useCommand.
La nuova funzione hook useCommand accetta come input il percorso e il metodo HTTP della chiamata di rete. L’argomento che accetta è uno solo, che racchiude entrambe le informazioni (path e method) per la stessa ragione per cui abbiamo fatto la stessa cosa con gli stati e per cui React la fa con le props: gli argomenti devono contenere tutte e sole le informazioni che descrivono l’argomento della funzione, in questo caso “la chiamata di rete quando non abbiamo ancora i dati a disposizione”.
La nuova funzione hook useCommand restituisce un array con due elementi: lo stato di rete - che utilizziamo nello stesso modo in cui l’abbiamo utilizzato precedentemente - e la funzione per far partire la richiesta di rete. La funzione per far partire la richiesta di rete accetta come argomento i dati da inviare, sottolineando il fatto che i dati sono disponibili in un momento del tempo diverso dal percorso e dal metodo HTTP che descrivono la richiesta.
Nota: il fatto che useCommand restituisca un array con due elementi, come fa useState, è una coincidenza. Non c’è nessuna regola sul formato dei valori restituiti dalle funzioni hook, né sul formato o sulla quantità di argomenti che possono accettare.
10.3
Esempi di hooks: richieste di rete di tipo query
Ora che sappiamo come creare una funzione hook e abbiamo una classe che rappresenta una richiesta di rete, possiamo pensare di creare una seconda funzione hook, per gestire, stavolta, le richieste di rete di tipo query.
Le richieste di tipo query sono quelle che partono appena viene visualizzata la pagina, per caricare i dati iniziali o quelli già presenti nel sistema perché salvati precedentemente.
La nuova funzione hook useQuery non sarà molto diversa da useCommand. Anzi, usiamo proprio useCommand come punto di partenza:
// File: src/hooks/useQuery.js
import { useCommand } from "./useCommand";
export function useQuery({ path, initialData }) {
const [networkRequest, sendNetworkRequest] = useCommand({
path,
method: "GET",
});
if (networkRequest.isIdle()) {
sendNetworkRequest(initialData);
}
return [networkRequest, sendNetworkRequest];
}
Concentriamoci sulle differenze con useCommand, che sono tre:
Non accettiamo method come parte dell’argomento (l’input della funzione), perché tutte le richieste di tipo query hanno il metodo HTTP “GET”. Infatti, quando chiamiamo useCommand, method è impostato con un valore costante “GET”.
Accettiamo un dato in più come parte dell’argomento (l’input della funzione), che è initialData. initialData ha lo stesso formato di data e rappresenta i dati che inviamo con la prima chiamata, quella che viene fatta partire in automatico.
Abbiamo aggiunto un passaggio rispetto all’uso di useCommand:
if (networkRequest.isIdle()) {
sendNetworkRequest(initialData);
}
La prima volta che un componente che usa useQuery viene visualizzato (i.e.: la funzione/componente viene chiamata da React), useQuery chiama immediatamente sendNetworkRequest passando initialData. Nel nostro caso, avendo una funzione che simula le richieste di rete, initialData corrisponde alla risposta della chiamata di rete.
In un caso realistico, initialData potrebbe contenere dei parametri da inviare con la query della chiamata di rete (quella composta da ? seguito da una serie di coppie key=value separate da &), come il numero di elementi per pagina nel caso di una chiamata che si aspetta una lista di elementi.
Sapendo come funzionano i cicli di rendering di React, possiamo immaginare cosa succederà dopo la chiamata a sendNetworkRequest iniziale:
sendNetworkRequest farà cambiare immediatamente da useCommand lo stato di networkRequest, facendolo transitare da “idle” a “loading”, perché useCommand chiama internamente networkRequest.load(). Essendo cambiato uno stato, il componente che usa useQuery viene chiamato al rendering. useQuery viene chiamata di nuovo, ma stavolta networkRequest.isIdle() non è più vero, perché networkRequest è in stato di loading (caricamento). Di conseguenza, sendNetworkRequest non viene più chiamata, evitando un ciclo infinito.
Dato che questo esempio sfrutta una serie di meccanismi di React, riassumiamo ancora una volta cosa succede:
Un componente viene visualizzato in pagina.
La funzione che rappresenta il componente viene chiamata.
La funzione che rappresenta il componente chiama useQuery, passando i dati iniziali (initialData).
useQuery crea lo stato networkRequest, utilizzando useCommand, che inizializza la macchina a stati finiti in stato di “idle”.
useQuery controlla se networkRequest è in stato di “idle”: lo è. Viene chiamata la funzione sendNetworkRequest passando i dati iniziali (initialData).
useCommand chiama internamente networkRequest.load(), che consente la transizione dallo stato di “idle” allo stato di “loading”. useCommand, internamente, fa anche partire la chiamata di rete e restituisce una Promise.
React reagisce al fatto che lo stato della chiamata di rete sia passato da “idle” a “loading”, che coincide con una chiamata a una funzione setter, scatenando un ciclo di rendering. Il componente che usa useQuery viene chiamato al rendering.
La funzione che rappresenta il componente viene chiamata.
La funzione che rappresenta il componente chiama useQuery.
useQuery controlla se networkRequest è in stato di “idle”: non lo è, è in stato di “loading”. sendNetworkRequest non viene chiamata, ma useCommand è ancora in ascolto della Promise che corrisponde alla chiamata di rete.
Passa del tempo, mentre l’interfaccia sta rappresentando lo stato di “loading”. L’unica parte del sistema in attesa è la funzione passata a then dentro useCommand.
Immaginando che la chiamata di rete abbia successo, la richiesta di rete risolve la Promise. La funzione callback passata a then da useCommand viene chiamata. Questa chiama a sua volta networkRequest.succeed, che fa transire la macchina a stati finiti dallo stato di “loading” allo stato di “succeeded”.
React reagisce al fatto che sia stata chiamata una funzione setter (con il cambio di stato da “loading” a “succeeded”) scatenando un ciclo di rendering. Il componente che usa useQuery viene chiamato al rendering.
La funzione che rappresenta il componente viene chiamata.
La funzione che rappresenta il componente chiama useQuery.
useQuery controlla se networkRequest è in stato di “idle”: non lo è, è in stato di “success”. sendNetworkRequest non viene chiamata.
In questo esempio abbiamo due funzioni hook e una parte di interfaccia grafica che vengono orchestrate da React. Possiamo notare una situazione frattale, in cui sia i singoli pezzi del nostro sistema, che il sistema nel suo intero, funzionano nello stesso modo:
La classe NetworkRequest contiene una rappresentazione dei suoi possibili stati nella proprietà privata #state; useQuery la contiene in networkState tramite useCommand; gli input e i bottoni nel componente TodoItemForm (che usa a sua volta useCommand) contengono la rappresentazione degli stati nell’attributo disabled. Tutti gli stati sono legati tra loro.
React è in ascolto delle chiamate alle funzioni setter; useQuery, tramite useCommand, ascolta la Promise restituita da sendNetworkRequest e notifica React quando avvengono cambiamenti; NetworkRequest è in ascolto delle chiamate ai metodi load, succeed e fail.
Quando un evento scatena una transizione, questa si propaga fino a React, che chiama un ciclo di rendering e permette a tutte le parti del sistema di aggiornarsi in modo sincronizzato, per rappresentare il nuovo stato. Qui sta la convenienza delle funzioni hook.
11
Rendering condizionale in React
11.1
Rendering condizionale in React
Abbiamo creato una funzione hook, useQuery, che ci permette di gestire delle richieste di rete, facendole partire non appena il componente che usa useQuery viene chiamato per la prima volta.
Possiamo pensare di creare un componente che rappresenti, tramite interfaccia grafica, una richiesta di rete. Questo ci permetterà di gestire nello stesso modo tutte le parti di applicazione che dipendono da dati che arrivano dalla rete, rendendo incredibilmente più veloce lo sviluppo e, allo stesso tempo, dando coerenza al comportamento dell’interfaccia grafica.
Utilizziamo App per ospitare questo nuovo componente, immaginando che esista una lista di cose da fare già salvata dall’utente nelle sessioni precedenti. Questa è anche un’ottima occasione per parlare di rendering condizionale.
Il rendering condizionale è la tecnica che permette di ottenere elementi o componenti diversi a seconda dello stato dell’applicazione. Cominciamo con un esempio semplice.
If-else in React
// File: src/components/Query.jsx
import { useQuery } from "../hooks/useQuery";
export default function Query({ path, initialData }) {
const [networkRequestState, retry] = useQuery({ path, initialData });
if (networkRequestState.isIdle()) {
return null;
} else if (networkRequestState.isLoading()) {
return <p style={{ textAlign: "center" }}>Loading...</p>;
} else if (networkRequestState.isFailure()) {
return (
<p style={{ textAlign: "center", color: "red" }}>
Error! {networkRequestState.error.message}
<button onClick={retry}>Retry</button>
</p>
);
} else if (networkRequestState.isSuccessful()) {
return <p style={{ textAlign: "center" }}>Success!</p>;
} else {
return null;
}
}
Il nuovo componente Query non è altro che una rappresentazione grafica di useQuery. Accetta gli stessi argomenti (che in questo caso sono props), che vengono passati tali e quali a useQuery. Per ogni stato possibile della macchina a stati finiti che rappresenta la richiesta di rete, c’è una vista grafica diversa.
Penseremo dopo al fatto che nel caso di stato “successful” non possiamo visualizzare un paragrafo con scritto “Success!” perché l’utente non saprebbe cosa farsene. Per adesso, concentriamoci sui vari modi di fare rendering condizionale.
Il primo modo di fare rendering condizionale è utilizzando if-else (o come in questo caso, if/else-if/else). I blocchi condizionali (if-else, appunto), sono nati per questo motivo. La programmazione imperativa ha portato una serie di varianti, come la programmazione difensiva, per esempio:
if (somethingWrong) {
return;
}
goAhead();
ma i blocchi condizionali sono stati creati per dividere in due il flusso del codice:
if (something) {
goOneDirection();
} else {
goAnotherDirection();
}
In programmazione dichiarativa si tende a utilizzare if-else solo quando le strade che si possono prendere sono solo due, cioè tipicamente quando si tratta di valori booleani, come nell’ultimo esempio. Quando le opzioni possibili sono più di una, ha più senso utilizzare uno switch-case.
Switch-case in React
Non possiamo utilizzare uno switch-case con NetworkRequest, perché siamo andati troppo avanti e abbiamo isolato la gestione delle richieste di rete nella classe. Possiamo, però, fingere di non averlo mai fatto e di avere ancora a disposizione uno stato networkRequest che ha una proprietà type e un oggetto NetworkRequestType con i possibili valori di type:
/*
export const NetworkRequestType = {
idle: "idle",
loading: "loading",
success: "success",
failure: "failure",
};
*/
import { useQuery } from "../hooks/useQuery";
export default function Query({ path, initialData }) {
const [networkRequestState, retry] = useQuery({ path, initialData });
switch (networkRequestState.type) {
case NetworkRequestType.idle:
return null;
case NetworkRequestType.loading:
return <p style={{ textAlign: "center" }}>Loading...</p>;
case NetworkRequestType.success:
return (
<p style={{ textAlign: "center", color: "red" }}>
Error! {networkRequestState.error.message}
<button onClick={retry}>Retry</button>
</p>
);
case NetworkRequestType.failure:
return <p style={{ textAlign: "center" }}>Success!</p>;
default:
return null;
}
}
L’utilizzo di switch-case è più strettamente legato alla ramificazione di casi mutualmente esclusivi. If-else, complici anche le variazioni che sono state utilizzate storicamente, è uno strumento più versatile e, quindi, più ambiguo rispetto a questo caso d’uso specifico.
Come abbiamo detto, non possiamo usare switch-case con la classe NetworkRequest. Inoltre, abbiamo sempre bisogno di un caso di default, perché JavaScript non sa che abbiamo abbastanza confidenza per non averne bisogno.
Il problema principale è che switch-case, in questo caso, implica una conoscenza della struttura interna della richiesta di rete di cui non vogliamo dare la responsabilità a chi utilizza il componente Query e neanche la nostra funzione hook useQuery, perché quel tipo di responsabilità è isolato all’interno di NetworkRequest. Come facciamo allora? Modifichiamo NetworkRequest!
Matching functions in React
In programmazione funzionale i protagonisti non sono gli oggetti, ma i verbi. Non ci importa cosa sono le cose, ma cosa sanno fare. Un’operazione molto comune in programmazione funzionale è quella del matching.
L’operazione di matching è quella di prendere in considerazione tutti i casi possibili e definire una direzione diversa per ogni caso. If-else è un’operazione di matching con un valore booleano. Possiamo immaginare operazioni di matching per gruppi di valori, come stringhe vuote e non vuote, numeri negativi, nulli e positivi, array vuoti e non vuoti, valori nulli o non nulli, operazioni che hanno successo o fallimento.
Ecco un esempio di operazione di matching per la nostra classe NetworkRequest:
// File: src/hooks/NetworkRequest.js
export class NetworkRequest {
// ...
match({ whenIdle, whenLoading, whenSuccessful, whenFailure }) {
if (this.isIdle()) {
return whenIdle();
} else if (this.isLoading()) {
return whenLoading();
} else if (this.isSuccessful()) {
return whenSuccessful(this.#state.data);
} else if (this.isFailure()) {
return whenFailure(this.#state.error);
} else {
throw new Error(`Unknown state for NetworkRequest: ${this.#state}`);
}
}
}
Ed ecco come possiamo usarla:
// File: src/components/Query.tsx
import { useQuery } from "../hooks/useQuery";
export default function Query({ path, initialData }) {
const [networkRequestState, retry] = useQuery({ path, initialData });
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: () => <p style={{ textAlign: "center" }}>Success!</p>,
});
}
Possiamo notare che:
Il metodo match di NetworkRequest utilizza if-else al suo interno, ma sapendo con certezza che #state può essere un’istanza di quattro possibili classi e nient’altro se non quello, possiamo permetterci di lanciare un errore dentro else perché non succederà mai. NetworkRequest isola tutte le sue funzionalità e non può essere modificata senza una conoscenza approfondita del suo funzionamento e, in più, descrive tutti e soli gli stati di una richiesta di rete. Non cambierà funzionamento a meno che non cambi la tecnologia con cui funzionano le richieste di rete - che, per inciso, funzionano così dal 2000 - caso in cui andrebbe rivista probabilmente l’intera applicazione.
Il metodo match di NetworkRequest ci costringe a prendere sempre in considerazione tutti i casi disponibili. Questo è essenziale perché sappiamo che la richiesta di rete passa per almeno tre dei quattro casi ogni volta (“idle”, anche se, nel caso di query, per molto poco; “loading” sempre; almeno uno tra “success” o “failure”).
IIFE/IIAE in React
L’ultima cosa che ci potrebbe servire per utilizzare la tecnica del rendering condizionale insieme alla programmazione dichiarativa, è un metodo per assegnare direttamente il risultato di un if-else o di uno switch-case a una variabile.
Immaginiamo un caso del genere:
export default function WeekDay({ weekDayNumber }) {
switch (weekDayNumber) {
case 0:
return <p>Monday</p>;
case 1:
return <p>Tuesday</p>;
case 2:
return <p>Wednesday</p>;
case 3:
return <p>Thursday</p>;
case 4:
return <p>Friday</p>;
default:
return null;
}
}
Riceviamo un numero da 0 a 4 e lo trasformiamo nel nome di un giorno della settimana. Facile! Ora, però, vorremmo isolare quell’elemento p uguale per tutti, e chiudere tutto dentro un div per poterlo selezionare in CSS. Quello che si fa di solito è dichiarare una variabile, popolarla tramite lo switch-case, per poi iniettarla in JSX, in questo modo:
export default function WeekDay({ weekDayNumber }) {
let paragraph;
switch (weekDayNumber) {
case 0:
paragraph = "Monday";
case 1:
paragraph = "Tuesday";
case 2:
paragraph = "Wednesday";
case 3:
paragraph = "Thursday";
case 4:
paragraph = "Friday";
default:
paragraph = null;
}
return (
<div className="WeekDay">
<p>{paragraph}</p>
</div>
);
}
La forma nell’esempio soprastante è perfettamente valida, ma non particolarmente fluida:
la variabile paragraph compare due volte più una per ogni possibile valore che la prop weekDayNumber può assumere. In più, essendo una variabile, chiunque potrebbe assegnarle un valore diverso in qualsiasi punto del codice, consapevolmente o per errore. Per non parlare del fatto che, trattandosi di linguaggio JavaScript, quel valore potrebbe essere qualsiasi cosa (un numero, null, un array con duemila elementi, un’emoticon, un pacchetto di caramelle..)
In questo esempio molto semplice la cosa sarebbe immediatamente evidente, ma immaginiamo un esempio più complesso, in cui ci sono dieci di questi casi.
Se saltasse fuori un bug per cui paragraph ha valore “July”, perché qualcuno l’ha scambiata per qualcos’altro (il nome paragraph è volutamente ambiguo), dovremmo andare a trovare tutti i punti in cui compare paragraph, seguire tutti i valori che le sono stati assegnati e capire dove, e soprattutto perché, ha ricevuto un valore sbagliato.
Visto che il nostro obiettivo è quello di metterci nelle condizioni di non poter sbagliare, la soluzione perfetta sarebbe quella che ci permette di:
Calcolare il valore di paragraph in un solo punto, così che non debba più essere modificata, rendendola una costante.
Effettuare il calcolo esattamente dove ci serve, così da non dover nemmeno dichiarare una costante da iniettare in JSX, per cui doverci inventare un nome, che potrebbe essere ambiguo.
In alcuni linguaggi di programmazione, è possibile assegnare il risultato di un blocco condizionale (if-else o switch-case). Questo, per esempio, è un esempio in Rust, in cui l’operazione di switch-case si chiama (guarda caso) match:
let week_day_string = match week_day_number {
0 => "Monday",
1 => "Tuesday",
2 => "Wednesday",
3 => "Thursday",
4 => "Friday",
_ => "",
};
In JavaScript, purtroppo, non ci è permesso scrivere una cosa del genere:
// Errore di sintassi!
const paragraph = switch (weekDayNumber) {
case 0:
paragraph = "Monday";
case 1:
paragraph = "Tuesday";
case 2:
paragraph = "Wednesday";
case 3:
paragraph = "Thursday";
case 4:
paragraph = "Friday";
default:
paragraph = null;
}
Di conseguenza, anche questo non ci è concesso:
export default function WeekDay({ weekDayNumber }) {
// Errore di sintassi!
return (
<div className="WeekDay">
{switch (weekDayNumber) {
case 0:
return "Monday";
case 1:
return "Tuesday";
case 2:
return "Wednesday";
case 3:
return "Thursday";
case 4:
return "Friday";
default:
return null;
}}
</div>
);
}
In compenso, il linguaggio JavaScript è un linguaggio interpretato, il che ci permette di dichiarare funzioni “al volo” ed eseguirle istantaneamente in totale libertà. Queste funzioni si chiamano Immediately Invoked Function Expressions (espressioni funzionali invocate immediatamente), spesso abbreviate con l’acronimo IIFE, e funzionano così:
Definiamo una funzione anonima, per esempio:
function() {
return "Hello World!";
}
La abbracciamo tra due parentesi tonde. Queste non hanno nessun effetto da un punto di vista sintattico (in JavaScript (((((2))))) === 2), ma la trasformano in un valore:
(function () {
return "Hello World!";
});
Ora che abbiamo un valore, la chiamiamo. Questo la eseguirà istantaneamente, trasformandola nel suo valore di ritorno:
(function () {
return "Hello World!";
})();
Quello che è scritto nell’esempio del punto 3. è equivalente alla stringa "Hello World!", il che vuol dire che possiamo assegnarla a una costante:
const helloWorld = (function () {
return "Hello World!";
})();
Il codice soprastante è equivalente a const helloWorld = "Hello World!", ma ci dà l'opportunità di usare una funzione per restituire il valore, che vuol dire che possiamo inserire logiche più complesse, per esempio proprio un if-else o uno switch-case.
Possiamo usare una IIFE per rivedere il nostro componente di esempio:
export default function WeekDay({ weekDayNumber }) {
return (
<div className="WeekDay">
<p>
{(function () {
switch (weekDayNumber) {
case 0:
return "Monday";
case 1:
return "Tuesday";
case 2:
return "Wednesday";
case 3:
return "Thursday";
case 4:
return "Friday";
default:
return null;
}
})()}
</p>
</div>
);
}
Ed ecco che possiamo effettuare i nostri calcoli dove ci servono, senza dichiarare costanti e senza rischiare di sbagliare!
La stessa cosa può essere fatta con le arrow function, creando Immediately Invoked Arrow Expressions (IIAE), con lo stesso procedimento:
() => "Hello World!";.
(() => "Hello World!");.
(() => "Hello World!")();.
const helloWorld = (() => "Hello World!")();.
export default function WeekDay({ weekDayNumber }) {
return (
<div className="WeekDay">
<p>
{(() => {
switch (weekDayNumber) {
case 0:
return "Monday";
case 1:
return "Tuesday";
case 2:
return "Wednesday";
case 3:
return "Thursday";
case 4:
return "Friday";
default:
return null;
}
})()}
</p>
</div>
);
}
Il metodo delle IIFE o IIAE è un’ottima alternativa alle funzioni di matching, che fanno comunque lo stesso lavoro con gli stessi vantaggi:
function matchWeekDayNumber(
weekDayNumber,
{ when0, when1, when2, when3, when4 }
) {
switch (weekDayNumber) {
case 0:
return when0();
case 1:
return when1();
case 2:
return when2();
case 3:
return when3();
case 4:
return when4();
default:
throw new Error(`Invalid week day number: ${weekDayNumber}`);
}
}
export default function WeekDay({ weekDayNumber }) {
return (
<div className="WeekDay">
<p>
{matchWeekDayNumber(weekDayNumber, {
when0: () => "Monday",
when1: () => "Tuesday",
when2: () => "Wednesday",
when3: () => "Thursday",
when4: () => "Friday",
})}
</p>
</div>
);
}
Le funzioni di matching sono più utili in casi più complessi, quando le logiche interne di una parte di applicazione sono isolate e vengono riutilizzate, come nel caso della nostra classe NetworkRequest. Quando si tratta di casi una tantum o particolarmente semplici, o al contrario quando la logica per calcolare un valore non è un if-else o uno switch-case, ma qualcosa di più complesso (che non ha necessariamente a che fare con le macchine a stati finiti), le IIFE/IIAE sono la soluzione più indicata.
Operatori ternari in React
Se sei arrivato fin qui, probabilmente avrai pensato almeno una volta: “e gli operatori ternari?” Detto fatto! In questo paragrafo ci occuperemo proprio di quelli.
Gli operatori ternari ci permettono di esprimere su una sola riga o assegnare a una variabile il risultato di un if-else, per esempio:
export default function MyMessage({ messageString }) {
return (
<div className="MyMessage">
{messageString === "" ? null : <p>{messageString}</p>}
</div>
);
}
Alcuni programmatori informatici non sono grandi fan degli operatori ternari, perché li reputano come “frasi che contengono troppo poca punteggiatura”. Finché si tratta di if-else semplici, non è troppo difficile leggerli, ma spesso si preferisce di gran lunga questa forma:
export default function MyMessage({ messageString }) {
return (
<div className="MyMessage">
{(() => {
if (messageString === "") {
return null;
} else {
return <p>{messageString}</p>;
}
})()}
</div>
);
}
All’inizio, le righe che dicono {(() => { e })()} possono confondere, con tutte quelle parentesi di fila, ma ti assicuriamo che, dopo un po’, comincerai a escluderle in automatico quando leggerai il codice.
L’esempio soprastante è un caso limite, ma prendiamo un caso più complesso:
export function Age({ birthday }) {
const now = new Date();
const differenceYears = user.dateOfBirth.getFullYear() - now().getFullYear();
const differenceMonths = user.dateOfBirth.getMonth() - now().getMonth();
const differenceDays = user.dateOfBirth.getDate() - now().getDate();
return (
<div className="Age">
<p>
Your age is:{" "}
<span>
{differenceMonths > 0
? differenceYears
: differenceMonths === 0
? differenceDays > 0
? differenceYears
: differenceYears - 1
: differenceYears - 1}
</span>
</p>
</div>
);
}
Anche con Prettier che ce la mette tutta per formattare il codice, questo esempio è quasi illeggibile. L’algoritmo che rappresenta non è dei più semplici di per sé e l’ammasso di ? e : peggiora la situazione. La forma IIAE è incredibilmente migliore:
export function Age({ birthday }) {
const now = new Date();
const differenceYears = user.dateOfBirth.getFullYear() - now().getFullYear();
const differenceMonths = user.dateOfBirth.getMonth() - now().getMonth();
const differenceDays = user.dateOfBirth.getDate() - now().getDate();
return (
<div className="Age">
<p>
Your age is:{" "}
<span>
{(() => {
if (differenceMonths > 0) {
return differenceYears;
} else if (differenceMonths === 0) {
if (differenceDays > 0) {
return differenceYears;
} else {
return differenceYears - 1;
}
} else {
return differenceYears - 1;
}
})()}
</span>
</p>
</div>
);
}
Dunque, gli operatori ternari pur essendo la scelta più semplice quando si tratta di if-else “in un colpo solo”, come abbiamo appena visto, non sono sempre la scelta migliore. Questa è una questione di preferenze, non una regola: sentiti libero di utilizzare la forma che più ti piace!
12
Componenti dichiarativi in React
12.1
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 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:
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.
13
Effetti collaterali in React
13.1
Che cos'è una funzione pura
Il prossimo concetto è forse il più delicato da affrontare, perché chi è abituato alla programmazione imperativa, quando lo scopre, non vuole più farne a meno. Vedremo perché questo concetto è così affine alla programmazione imperativa e anche perché, in React, la cosa è molto pericolosa.
In programmazione funzionale, che è uno dei quattro grandi gruppi della programmazione dichiarativa, tutte le funzioni sono funzioni pure.
Che cos’è una funzione pura?
Una funzione pura è una funzione che usa solo gli argomenti che riceve, più eventuali variabili dichiarate all’interno della funzione, per trasformarli in qualcos’altro, che viene restituito. Facciamo un esempio:
// Funzione pura: number => number
function sum(a, b) {
return a + b;
}
// Funzione pura: User => boolean
function isOfAge(user) {
const now = new Date();
const differenceYears = user.dateOfBirth.getFullYear() - now().getFullYear();
const differenceMonths = user.dateOfBirth.getMonth() - now().getMonth();
const differenceDays = user.dateOfBirth.getDate() - now().getDate();
const age = (() => {
if (differenceMonths > 0) {
return differenceYears;
} else if (differenceMonths === 0) {
if (differenceDays > 0) {
return differenceYears;
} else {
return differenceYears - 1;
}
} else {
return differenceYears - 1;
}
})();
return age >= 18;
}
Quando una funzione non è pura?
Una funzione non è pura (e, quindi, si definisce spuria) quando fa più di una cosa, per esempio salvare un valore in local storage o session storage, scrivere qualcosa in console, lanciare un errore. Queste operazioni si chiamano effetti collaterali, perché causano comportamenti in più rispetto a quello della funzione, che è quello di trasformare qualcosa in ingresso in qualcosa in uscita.
In programmazione funzionale, perfino modificare un valore è considerato un effetto collaterale. Per esempio:
let n = 0;
n = 42; // <- Effetto collaterale
const response = { status: false };
response.status = true; // <- Effetto collaterale
const list = [1, 2, 3];
list[1] = 20; // <- Effetto collaterale
Le 3 proprietà delle funzioni pure
Le funzioni pure hanno tre proprietà che le rendono importanti per la programmazione informatica:
Riutilizzabilità: le funzioni pure possono essere chiamate infinite volte e si comporteranno sempre nello stesso modo. Non facendo nient’altro che trasformare qualcosa in qualcos’altro, dato lo stesso input, restituiranno sempre lo stesso output. Di contro, le funzioni spurie, che contengono effetti collaterali, potrebbero avere comportamenti diversi a seconda di valori che non hanno a che fare con il loro input.
Componibilità: se una funzione trasforma A in B e un’altra trasforma B in C, possiamo comporle per trasformare A in C, anche se non conosciamo il funzionamento interno del sistema (perché non abbiamo scritto noi il codice, per esempio). Questo finché le funzioni sono pure, altrimenti trasformeremmo A in C e ci troveremmo una serie di effetti collaterali che possiamo prevedere solo leggendo il codice delle funzioni.
Confidenza: quando le funzioni sono pure, è più facile fidarsi di loro. Una funzione che si chiama isOfAge(user) e ci dice se un utente è maggiorenne, può essere usata ad occhi chiusi. Se, invece, sappiamo che la funzione possa contenere effetti collaterali - per esempio mandare una richiesta di rete per inviare un’e-mail - non possiamo sapere se è sicuro utilizzarla finché non sappiamo esattamente cosa fa.
Il fatto che gli stati in React non possano essere modificati ma solo rimpiazzati in toto, deriva proprio da questa regola della programmazione funzionale. Per lo stesso motivo, non vedrai esempi in questa guida che utilizzano let o che modificano strutture dati sul posto, a meno che non siano esempi legati alla programmazione imperativa.
13.2
Gli effetti collaterali in React
Abbiamo detto che le funzioni pure ci regalano riutilizzabilità, componibilità e confidenza, ma non possiamo rinunciare agli effetti collaterali: avremo sempre bisogno di salvare dati, mandare richieste di rete, modificare strutture di dati.
React prevede due momenti in cui possiamo eseguire effetti collaterali, uno per React e uno per noi:
Il rendering: l’attività con cui React modifica il DOM (gli elementi HTML) per visualizzare il nostro codice JSX, è un effetto collaterale. Quello che fanno i nostri componenti è trasformare props e/o stati in JSX, dopodiché, alla fine di ogni ciclo di rendering, React applica l’effetto collaterale di inserire gli elementi derivati da JSX nella pagina HTML.
Le funzioni hook: le funzioni setter restituite da useState rappresentano effetti collaterali. I nostri hook useQuery e useCommand rappresentano effetti collaterali, per cui, oltre a trasformare le props e gli stati in JSX, lanciamo delle chiamate di rete.
Gli effetti collaterali - che in programmazione imperativa possono essere anche il 100% del codice - sono i più difficili da individuare per le persone non abituate alla programmazione dichiarativa. Per fortuna, in React, essendo i componenti stessi funzioni, è più facile rispondere alle domande “il mio componente fa qualcos’altro oltre a trasformare le props e gli stati in JSX? Se sì, ho isolato gli effetti collaterali dentro funzioni hook?”.
Ora che sappiamo cosa sono gli effetti collaterali, possiamo vedere il prossimo hook incluso in React, creato apposta per eseguire effetti collaterali: useEffect.
useEffect in React
Come abbiamo appena accennato, useEffect è la funzione hook che React ci fornisce per applicare effetti collaterali all’interno dei nostri componenti. Questa è la sintassi generica di useEffect:
useEffect(
() => {
// side effect
// optional
return () => {
// cleanup
};
},
[
/* dependencies */
]
);
useEffect è una funzione che accetta due argomenti:
La funzione che rappresenta l’effetto collaterale. Questa funzione non può avere un valore di ritorno (in altre parole, deve restituire void). Gli effetti collaterali non restituiscono mai un valore perché, per definizione, fanno qualcosa di slegato dal resto del sistema. La funzione che passiamo come primo argomento di useEffect può restituire una seconda funzione, che possiamo chiamare cleanup function (in italiano, funzione di pulizia). La cleanup function è una funzione che rimuove eventuali effetti collaterali persistenti. Per esempio:
useEffect(() => {
const onWindowResize = () => {
console.log("The window has been resized!");
};
window.addEventListener("resize", onWindowResize);
return () => {
window.removeEventListener("resize", onWindowResize);
};
}, []);
In questo esempio, ascoltiamo l’evento nativo del browser che scatta quando la finestra viene ridimensionata. La nostra cleanup function smette di ascoltare. Non siamo costretti a restituire una cleanup function, possiamo anche non restituire niente. Quando applichiamo un effetto permanente, però, per esempio, appunto, ascoltare un evento, restituiamo una cleanup function, altrimenti continueremmo ad ascoltare l’evento anche dopo che il componente è stato rimosso dalla pagina, creando errori e sprecando risorse del browser.
Un array di dipendenze. Le dipendenze sono le props o gli stati che devono scatenare l’effetto collaterale in caso di aggiornamento. Per esempio:
export default function Profile({ authStatus }) {
useEffect(() => {
authStatus.match({
whenAnonymous: () => {
window.localStorage.removeItem("auth");
},
whenLoggedIn: (credentials) => {
window.localStorage.setItem(
"auth",
JSON.stringify(authStatus.credentials)
);
},
});
}, [authStatus]);
}
In questo esempio, immaginiamo di avere uno stato, da qualche parte in un componente controller, che rappresenta lo stato di autenticazione dell’utente. authStatus fornisce una funzione di matching che ha due stati: “anonimo” ("anonymous") e “autenticato” ("logged_in").
Quando l’utente è autenticato, la funzione di matching di authStatus fornisce la proprietà credentials, che contiene le credenziali di autenticazione dell’utente, come il token di autenticazione (mai salvare la password da nessuna parte!).
Lo stato di autenticazione viene passato al nostro componente view Profile, che salva le credenziali nell’archivio locale del browser se l’utente è autenticato, altrimenti (se è anonimo) le elimina. In questo caso, il nostro effetto collaterale deve essere eseguito ogni volta che lo stato di autenticazione viene aggiornato, così che possiamo reagire a login e logout eseguendo le operazioni corrette.
Aggiungiamo, quindi, authStatus all’array delle dipendenze dell’effetto, così che React esegua la funzione ogni volta che viene chiamata la funzione setter dello stato che si trova nel componente controller, e di conseguenza la prop viene aggiornata.
Attenzione: anche in questo caso, come per i cicli di rendering, non è esatto dire che gli effetti vengono eseguiti quando le cose cambiano. Gli effetti vengono eseguiti ogni volta che viene chiamata la funzione setter dello stato corrispondente alla nostra dipendenza, anche se il prossimo valore dello stato è identico a quello corrente. Tutti gli effetti, a prescindere dalle loro dipendenze, vengono anche eseguiti quando il componente che li contiene viene creato.
Le dipendenze degli effetti in React
La regola di React per le dipendenze degli effetti è questa: tutte le variabili che vengono citate all’interno della funzione che rappresenta l’effetto (quella che passiamo come primo argomento a useEffect) devono essere inserite tra le dipendenze, tranne le funzioni setter degli stati.
Attenzione: se una prop è, in realtà, una funzione setter passata dal componente controller, non vale come funzione setter, va comunque inclusa tra le dipendenze. Le uniche funzioni setter che si possono omettere sono quelle che fanno riferimento a stati nello stesso componente in cui usiamo useEffect.
React, attraverso ESLint, ci darà un avvertimento se una delle variabili citate all’interno della funzione che rappresenta l’effetto non fa parte delle dipendenze, o se ne abbiamo messe di troppo. Ci dirà anche che possiamo aggiungere un commento nella riga di codice precedente a quella in cui abbiamo dichiarato le dipendenze, per spegnere l’avvertimento: non farlo mai. Nel 99% dei casi, se una variabile citata all’interno della funzione che rappresenta l’effetto non fa parte delle dipendenze, significa che c’è un errore di logica nel modo in cui stiamo progettando il sistema.
Il ciclo di rendering di React e gli effetti collaterali
Sappiamo come funziona il ciclo di rendering di React quando abbiamo a che fare con props, stati ed eventi:
Al primo ciclo di rendering, tutte le funzioni che rappresentano i componenti in pagina vengono eseguite con i loro stati iniziali.
Quando un evento scatena la chiamata di una funzione setter, tutti i componenti che usano lo stato che è cambiato, e tutti i loro figli, vengono chiamati al rendering con il nuovo valore dello stato.
Ma come si comporta React con gli effetti collaterali? Gli effetti collaterali vengono eseguiti dopo il ciclo di rendering:
Tutti gli effetti collaterali che dipendono dallo stato che è cambiato vengono eseguiti.
Nella prossima sezione, analizzeremo una serie di casi in cui l’uso di effetti collaterali è sconsigliato, e il fatto che scatenino cicli di rendering è uno dei motivi per cui lo è.
13.3
Quando evitare gli effetti collaterali in React
Gli effetti collaterali sono il concetto più mal interpretato e impropriamente usato di React. Vediamo una lista di casi in cui potresti essere tentato ad usare un effetto collaterale, e come evitare di farlo.
In generale, ci sono due casi in cui gli effetti collaterali non servono mai:
per trasformare i dati in JSX
per gestire eventi
Vediamoli!
Trasformare i dati in JSX
Immaginiamo di voler filtrare una lista prima di mostrarla. La lista arriva tramite prop da un componente controller e noi vogliamo mostrare solo alcuni degli elementi. Potremmo essere tentati di creare uno stato, che rappresenta la “lista filtrata”, e utilizzare un effetto collaterale per modificarla ogni volta che cambia la lista originale (che sarebbe la prop):
export function MyList({ items }) {
const [filteredItems, setFilteredItems] = useState(items);
const [chosenCategory, setChosenCategory] = useState("all");
useEffect(() => {
if (chosenCategory === "all") {
setFilteredItems(items);
} else {
const filteredItems = items.filter((item) => {
return item.category === chosenCategory;
});
setFilteredItems(filteredItems);
}
}, [items, chosenCategory]);
// ...
}
In questo esempio, abbiamo due stati interni, che rappresentano una categoria scelta dall’utente e la lista filtrata per categoria. Quando la categoria scelta dall’utente (che possiamo immaginare essere scelta tramite un menù a tendina) cambia, scateniamo un effetto collaterale che modifica la lista filtrata. Allo stesso modo, quando la lista di partenza (items, che arriva tramite props) cambia, scateniamo lo stesso effetto collaterale.
Vediamo come React gestirebbe i cicli di rendering in questo caso:
Ciclo di rendering iniziale.
Ciclo di rendering quando viene chiamata la funzione setter setChosenCategory, oppure quando si aggiorna la prop items.
Ciclo di rendering subito dopo il secondo, perché i cambiamenti di chosenCategory e items scatenano useEffect, che a sua volta chiama setFilteredItems.
Il terzo ciclo di rendering non è necessario! filteredItems dipende direttamente da items e il nostro componente MyList viene già chiamato al rendering ogni volta che items si aggiorna:
export function MyList({ items }) {
const [chosenCategory, setChosenCategory] = useState("all");
const filteredItems = (() => {
if (chosenCategory === "all") {
return items;
} else {
return filteredItems.filter((item) => {
return item.category === chosenCategory;
});
}
})();
// ...
}
Gestire eventi
Il secondo caso generico è la gestione di operazioni che dipendono direttamente da eventi scatenati dall’utente. Per esempio, immaginiamo di avere un hook useNotification che ci permette di mostrare una notifica, e un bottone “Compra”: quando il bottone “Compra” viene attivato, mandiamo una richiesta di rete di tipo command. Se tutto va bene, inviamo una notifica di successo per far sapere all’utente che tutto è andato a buon fine. Utilizziamo il nostro hook useCommand per l’esempio:
export default function BuyButton({ product }) {
const [buyRequestNetworkState, sendBuyRequest] = useCommand({
path: "/products/buy",
method: "POST",
});
const { showNotification } = useNotification();
useEffect(() => {
if (buyRequestNetworkState.isSuccessful()) {
showNotification({
type: "success",
message: "You just bought the product!",
});
}
}, [buyRequestNetworkState]);
return (
<button
disabled={buyRequestNetworkState.isLoading()}
onClick={() => sendBuyRequest(product)}
>
Buy
</button>
);
}
In questo esempio, al click del bottone che dice “Buy”, inviamo una richiesta di tipo POST a una API al percorso /products/buy. Utilizziamo un effetto collaterale per mostrare la notifica di successo ogni volta che la richiesta di rete buyRequestNetworkState viene aggiornata. Se la richiesta è andata a buon fine, mostriamo la notifica.
Anche in questo caso, stiamo scatenando un ciclo di rendering in più rispetto a quello che ci serve. Possiamo rimuovere l’effetto collaterale e chiamare showNotification direttamente dentro una funzione per gestire l’evento, sfruttando il fatto che sendBuyRequest restituisce una Promise:
export default function BuyButton({ product }) {
const [buyRequestNetworkState, sendBuyRequest] = useCommand({
path: "/products/buy",
method: "POST",
});
const { showNotification } = useNotification();
const onButtonClick = () => {
sendBuyRequest(product).then(() => {
showNotification({
type: "success",
message: "You just bought the product!",
});
});
};
return (
<button
disabled={buyRequestNetworkState.isLoading()}
onClick={onButtonClick}
>
Buy
</button>
);
}
Questi erano due casi generici in cui si può evitare di utilizzare effetti collaterali, ma non temere, abbiamo anche un sacco di esempi specifici! Vediamone un paio!
Aggiornare stati in base a props o altri stati
Un esempio classico: abbiamo nome e cognome, vogliamo metterli insieme per ottenere il nome completo. Potresti essere tentato di fare una cosa del genere:
export default function Form() {
const [firstName, setFirstName] = useState("");
const [lastName, setLastName] = useState("");
const [fullName, setFullName] = useState("");
useEffect(() => {
setFullName(firstName + " " + lastName);
}, [firstName, lastName]);
// ...
}
Aggiungendo fullName come stato, dobbiamo, poi, preoccuparci di tenerlo sincronizzato con i due stati da cui deriva (firstName e lastName), ma non ci serve farlo.
Anche in questo caso, possiamo risparmiare uno stato e un ciclo di rendering utilizzando una banale costante. In più, firstName e lastName descrivono due proprietà della stessa cosa (tendenzialmente una persona) e abbiamo detto che gli stati dovrebbero rappresentare tutte e sole le informazioni che servono per descrivere la realtà:
export default function Form() {
const [person, setPerson] = useState({
firstName: "",
lastName: "",
});
const fullName = person.firstName + " " + person.lastName;
// ...
}
fullName dipende direttamente dallo stato di person, ci penserà React a chiamare la nostra funzione/componente Form quando verrà chiamata la funzione setPerson, così che fullName venga aggiornata.
Notificare il componente controller
Quando si parla di input (nel senso di elementi <input />), non sempre è possibile operare con un solo stato. Idealmente, lo stato e la sua funzione setter esistono una sola volta in un solo componente e vengono passati insieme ai componenti view.
Ci sono, però, dei casi in cui lo stato e l’input non coincidono, per esempio perché lo stato deve passare una validazione ma vogliamo che l’utente veda quello che sta scrivendo anche se è sbagliato, oppure perché l’utente sta scrivendo in un campo di ricerca e non vogliamo lanciare una chiamata di rete ogni volta che l’input cambia, ma solo quando l’utente ha finito di scrivere.
In questi casi, abbiamo bisogno di una prop e un evento per notificare il componente controller, ma anche di uno stato interno per tenere traccia dei cambiamenti. In questo caso, la tentazione di tenere sincronizzati lo stato con le prop è forte:
export default function MyInput({ value, validate, onChange }) {
const [state, setState] = useState(value);
useEffect(() => {
const isValid = validate(state);
if (isValid) {
onChange(state);
}
}, [state, validate, onChange]);
return (
<input
value={state}
onInput={(event) => {
setState(event.currentTarget.value);
}}
/>
);
}
Anche in questo caso, possiamo spostare entrambi gli aggiornamenti in una funzione per gestire l’evento:
export default function MyInput({ value, validate, onChange }) {
const [state, setState] = useState(value);
const onInput = (inputValue) => {
setState(inputValue);
const isValid = validate(state);
if (isValid) {
onChange(state);
}
};
return (
<input
value={state}
onInput={(event) => {
onInput(event.currentTarget.value);
}}
/>
);
}
Se, invece, possiamo “sollevare lo stato” e far gestire tutto al componente controller, quella è un’opzione migliore, perché ci permette di isolare la logica di funzionamento in un unico punto.
13.4
Quando usare gli effetti collaterali in React
Nella sezione in cui abbiamo parlato di useEffect, abbiamo visto alcuni ottimi esempi di utilizzo corretto degli effetti collaterali. Nella nostra applicazione di esempio, possiamo usare useEffect all’interno di useQuery e useQueryState, per sottolineare l’effetto collaterale che esiste già:
// File: src/hooks/useQuery.js
import { useEffect } from "react";
import { useCommand } from "./useCommand";
export function useQuery({ path, initialData }) {
const [networkRequest, sendNetworkRequest] = useCommand({
path,
method: "GET",
});
useEffect(() => {
if (networkRequest.isIdle()) {
sendNetworkRequest(initialData);
}
}, [networkRequest, sendNetworkRequest, initialData]);
return [networkRequest, sendNetworkRequest];
}
// File: src/hooks/useQueryState.js
import { useEffect, useState } from "react";
import { NetworkRequest } from "./NetworkRequest";
import { sendNetworkRequest } from "../mock/sendNetworkRequest";
export function useQueryState({ path, initialData }) {
const [queryState, setQueryState] = useState(NetworkRequest.create());
// 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);
});
}
);
};
const setNetworkState = (setStateAction) => {
setQueryState((state) => {
return state.map((data) => {
if (typeof setStateAction === "function") {
return setStateAction(data);
} else {
return setStateAction;
}
});
});
};
useEffect(() => {
if (queryState.isIdle()) {
sendQuery(initialData);
}
}, [queryState, sendQuery, initialData]);
return [queryState, sendQuery, setNetworkState];
}
Notiamo come, in useQueryState, abbiamo aggiunto un commento:
// FIXME:
// eslint-disable-next-line react-hooks/exhaustive-deps
Questo commento spegne un avvertimento di React che ha a che fare con le dipendenze dell’effetto, che è proprio quello che non bisognerebbe fare! Ed ecco perché abbiamo usato “FIXME:” per tracciare il problema, con la nostra estensione di VS Code (se l’hai installata).
Approfondimenti
La guida ufficiale di React ha una serie di capitoli interi legati agli effetti, perché, come abbiamo detto, sono il concetto più mal interpretato e impropriamente usato di React. Per approfondire ulteriormente con altri esempi, puoi, appunto, fare riferimento alla guida ufficiale:
You Might Not Need an Effect: parla di ciò di cui abbiamo appena parlato, con alcuni esempi molto simili e qualche esempio in più.
Separating Events from Effects: si concentra sulla differenza tra effetti collaterali e funzioni per gestire gli eventi.
Removing Effect Dependencies: esplora modi per ottenere effetti con meno dipendenze, per evitare di spegnere gli avvertimenti di React.
14
Portali in React
14.1
Portali in React
La nostra applicazione di esempio sa creare e leggere gli elementi di cose da fare. Delle quattro operazioni CRUD (Create, Read, Update, Delete), sappiamo fare le prime due. È ora di aggiungere la terza!
Per quanto riguarda l’interfaccia grafica, dato che abbiamo l’intera applicazione in un’unica pagina, implementeremo il form di modifica in una modale. Questo ci permetterà di scoprire un’altra funzione di React: createPortal.
Prima di continuare, però, ci serve un modo univoco per distinguere un elemento della lista dall’altro, che non sia la sua posizione nella lista (l’indice, che abbiamo usato per l’attributo key in precedenza). Non avendo a disposizione un server, scriveremo una piccola funzione che genera un id progressivo numerico. In una situazione realistica, non dovremmo preoccuparci di questa cosa, ci penserebbe il server.
// File: src/App.jsx
// ...
const initialData = [
{
id: 1,
description: "Something to be done",
isDone: false,
},
{
id: 2,
description: "Something already done",
isDone: true,
},
];
export default function App() {
// ...
const onTodoItemFormSubmit = (todoItem) => {
setTodoList((todoList) => {
const allIds = todoList.map((item) => {
return item.id;
});
const maxId = Math.max(0, ...allIds);
const newId = maxId + 1;
const itemWithId = { ...todoItem, id: newId };
return [itemWithId, ...todoList];
});
};
// return ...
}
Abbiamo aggiunto gli id 1 e 2 ai nostri dati iniziali, initialData. Quando creiamo un nuovo elemento, estraiamo prima tutti gli id dalla lista, troviamo il massimo, aggiungiamo uno e otteniamo l’id da assegnare al nuovo elemento.
Un’altra cosa che dobbiamo fare è rendere il nostro form TodoItemForm in grado di partire da dati già esistenti, mentre adesso parte sempre con tutti (due) i campi vuoti. Per farlo, aggiungiamo una prop opzionale che accetta, se esiste, lo stato corrente dell’elemento da rappresentare. Dopodiché, interveniamo sui valori iniziali passati a useForm, cioè le proprietà initialValue dei vari campi dell’oggetto fields, più la nuova proprietà id, che, in caso di modifica, deve essere inviata insieme al resto quando scatta l’evento “Submit”:
// File: src/components/TodoItemForm.jsx
// ...
export default function TodoItemForm({ initialData, onSubmit }) {
// ...
const {
inputProps,
onSubmit: onFormSubmit,
reset,
} = useForm({
fields: {
id: {
initialValue: initialData?.id,
},
description: {
initialValue: initialData?.description ?? "",
validator: nonEmptyStringValidator("Description cannot be empty!"),
},
isDone: {
initialValue: initialData?.isDone ?? false,
},
},
submit: (data) => {
createTodoItem(data).then((response) => {
onSubmit(response);
reset();
});
},
});
// ...
}
Ora TodoItemForm è pronto per modificare, oltre che creare elementi. I prossimi componenti che dobbiamo preparare sono TodoItem e TodoList.
Non vogliamo inserire il form di modifica in TodoItem, perché non vogliamo avere un elemento <form /> in pagina per ogni elemento della lista. Basterà un unico form per tutta la lista, che popoleremo con i dati dell’elemento che stiamo modificando volta per volta.
Il form andrà dentro TodoList, ma ci servirà un bottone “Edit” (modifica) per ogni elemento, così da poter impostare una “modalità di modifica” su un singolo elemento facendo click sul bottone “Edit” corrispondente.
Il modo in cui viene gestito il click dei bottoni “Edit” è l’ormai classico sollevamento di stato: al click del bottone “Edit”, TodoItem notificherà TodoList tramite un evento.
// File: src/components/TodoItem.jsx
import "./TodoItem.css";
export default function TodoItem({ todoItem, onEditButtonClick }) {
return (
<div className="TodoItem">
<input type="checkbox" checked={todoItem.isDone} disabled />
<span>{todoItem.description}</span>
<button onClick={onEditButtonClick}>Edit</button>
</div>
);
}
Nota: potrà capitarti di vedere chiamate a funzioni come onEditButtonClick - che rappresentano la gestione di eventi - passare dati che il componente controller ha sicuramente già a disposizione. Per esempio, potresti pensare che a onEditButtonClick vada passato l’ID univoco dell’elemento, chiamandola in modo simile a: onEditButtonClick(todoItem.id). Questa cosa non serve, perché todoItem è a sua volta una prop, il che significa che il componente controller, che usa TodoItem, deve avere a disposizione l’intero oggetto todoItem per poter usare il componente TodoItem. Di conseguenza, anche l’ID dell’elemento, todoItem.id, sarà disponibile al componente controller. Insomma, quando progetti la comunicazione tra componenti controller e componenti view, tieni sempre a mente dove sono i dati che verranno passati.
La funzione createPortal in React
createPortal ci serve quando vogliamo far uscire un componente dalla struttura ad albero che React crea, portandolo fuori dal flusso e inserendolo da un’altra parte nel DOM (la struttura di elementi HTML).
Il concetto è molto simile a quello di position: absolute; o position: fixed; in linguaggio CSS. E guarda caso, la position del nostro componente modale sarà proprio fixed!
// File: src/components/Modal.jsx
import "./Modal.css";
import { createPortal } from "react-dom";
export default function Modal({ isOpen, onCancel, children }) {
const modalClassName = ["Modal", ...(isOpen ? ["visible"] : [])].join(" ");
return createPortal(
<div className={modalClassName}>
<div className="content">
<button className="close-button" onClick={onCancel}>
×
</button>
{children}
</div>
</div>,
document.body
);
}
Una piccola nota su una sintassi che potrebbe non esserti familiare:
const modalClassName = ["Modal", ...(isOpen ? ["visible"] : [])].join(" ");
Ci sono tanti modi di comporre le liste di classi in React, questo è senza dubbio uno tra i più validi. Quello che vogliamo in questo caso è che "Modal" sia una classe sempre associata al nostro componente, mentre "visible" sarà presente solo quando la proprietà isOpen - che è un valore booleano - sarà true.
Usiamo lo spread operator (...) per scomporre un Array di stringhe, che sarà vuoto se isOpen sarà false, mentre conterrà un solo elemento, la stringa "visible", quando isOpen sarà true. Chiamiamo poi il metodo join per comporre la stringa che contiene le classi separate da spazi.
Con isOpen che vale false, la parte tra parentesi (isOpen ? ["visible"] : []) risolve in un Array vuoto []. Di conseguenza, modalClassName diventa ["Modal", ...[]]. La destrutturazione di un Array vuoto ...[] non dà nessun risultato, quindi modalClassName diventa ["Modal"]. A questo punto, join non aggiunge niente alla stringa, quindi modalClassName diventa "Modal".
Con isOpen che vale true, la parte tra parentesi (isOpen ? ["visible"] : []) risolve in ["visible"]. Di conseguenza, modalClassName diventa ["Modal", ...["visible"]]. La destrutturazione porta a ["Modal", "visible"] e join aggiunge uno spazio, quindi modalClassName diventa "Modal visible".
Questo metodo permette di inserire multiple classi che devono essere sempre presenti, multiple classi opzionali, più classi per ogni condizione e, se mischiata con le IIFE/IIAE, possiamo inserire if-else, switch-case, funzioni di matching e, in generale, un po’ quello che ci pare, per calcolare liste di classi, utilizzando sempre la stessa sintassi. Infine, non ci sono casi in cui questa tecnica aggiunge spazi non necessari, a differenza, per esempio, del classico:
<div className={`Modal ${isOpen ? "visible" : ""}`}>{/* ... */}</div>
Questa forma, quando isOpen è false, scrive className="Modal ", con un fastidiosissimo spazio inutile alla fine della stringa. La soluzione potrebbe essere:
<div className={`Modal${isOpen ? " visible" : ""}`}>{/* ... */}</div>
Il risultato di questa forma è più consistente, ma il codice è meno elegante e, se dovessimo avere tante classi opzionali, questa forma si trasformerebbe molto presto in un incubo di operatori ternari:
<div
className={`Modal${isOpen ? " visible" : ""}${
variant === "success"
? " success"
: variant === "error"
? " error"
: variant === "warning"
? " warning"
: " default"
}`}
>
{/* ... */}
</div>
Ora, createPortal:
// File: src/components/Modal.jsx
return createPortal(
<div className={modalClassName}>
<div className="content">
<button className="close-button" onClick={onCancel}>
×
</button>
{children}
</div>
</div>,
document.body
);
createPortal accetta due argomenti:
la parte di JSX che vogliamo inserire in pagina, quella che farà parte del rendering di React.
Il secondo è l’elemento HTML in da cui vogliamo partire.
Per il secondo argomento, usiamo le funzioni native del browser, come document.getElementById o document.querySelector. Nel nostro caso specifico vogliamo l’elemento <body />, che è sempre presente in pagina e accessibile tramite document.body.
Il resto del componente è abbastanza intuitivo: le props sono isOpen, che serve a mostrare o nascondere la modale; onCancel, collegato al click di un bottone per chiudere il modale; children, che contiene tutto il contenuto che verrà inserito nella modale.
/* File: src/components/Modal.css */
.Modal {
align-items: center;
background-color: rgba(0, 0, 0, 0.64);
bottom: 0;
display: flex;
justify-content: center;
left: 0;
opacity: 0;
position: fixed;
right: 0;
top: 0;
visibility: hidden;
}
.Modal.visible {
opacity: 1;
visibility: visible;
}
.Modal .content {
position: relative;
width: 90vw;
max-width: 45em;
background-color: #fff;
box-sizing: border-box;
padding: 48px calc(48px + 1.5em) 1.5em 1.5em;
border-radius: 8px;
}
.Modal .close-button {
width: 48px;
height: 48px;
font-size: 32px;
padding: 0;
line-height: 32px;
border: 0px none;
background-color: transparent;
position: absolute;
top: 0;
right: 0;
cursor: pointer;
}
Ora possiamo inserire il nostro form in TodoList e chiudere il cerchio:
Nota: TodoItemForm non verrà popolato correttamente con i dati relativi all’elemento di cui clicchiamo il bottone “Edit”. Questa cosa è normale, la sistemeremo nella prossima sezione.
// File: src/components/TodoList.jsx
import { useState } from "react";
import TodoItem from "./TodoItem";
import "./TodoList.css";
import Modal from "./Modal";
import TodoItemForm from "./TodoItemForm";
export default function TodoList({ todoList, onItemUpdate }) {
const [editingItem, setEditingItem] = useState(null);
const onModalCancel = () => {
setEditingItem(null);
};
const onEditFormSubmit = (data) => {
onItemUpdate(data);
setEditingItem(null);
};
return (
<div className="TodoList">
{todoList.map((todoItem) => {
return (
<TodoItem
key={todoItem.id}
todoItem={todoItem}
onEditButtonClick={() => {
setEditingItem(todoItem);
}}
/>
);
})}
<Modal isOpen={editingItem !== null} onCancel={onModalCancel}>
<TodoItemForm initialData={editingItem} onSubmit={onEditFormSubmit} />
</Modal>
</div>
);
}
Usiamo uno stato per memorizzare l’elemento che stiamo modificando, se ce n’è uno, altrimenti lo stato avrà valore null, che è anche il suo valore iniziale. Quando l’utente fa click su un bottone “Edit”, TodoItem chiama la nostra funzione associata a onEditButtonClick, che a sua volta imposta lo stato editingItem, chiamando la sua funzione setter e scatenando un ciclo di rendering.
Lo stato editingItem decide se la modale, rappresentata dal componente Modal, viene visualizzata. Dentro Modal abbiamo TodoItemForm, che invia i dati tramite onSubmit alla funzione onEditFormSubmit, la quale, a sua volta, chiude la modale (impostando editingItem a null, sempre tramite funzione setter e ciclo di rendering) e notifica il componente controller, in questo caso App.
Vediamo, infine, App:
// File: src/App.jsx
// ...
const initialData = [
// ...
];
export default function App() {
const [todoListNetworkRequest, retry, setTodoList] = useQueryState({
path: "/todo",
initialData,
});
const onTodoItemFormSubmit = (todoItem) => {
// ...
};
const onTodoItemUpdate = (updatedItem) => {
setTodoList((todoList) => {
return todoList.map((todoItem) => {
if (todoItem.id === updatedItem.id) {
return updatedItem;
} else {
return todoItem;
}
});
});
};
return (
<div className="App">
<Query
// ...
render={(todoList) => (
<>
<TodoList todoList={todoList} onItemUpdate={onTodoItemUpdate} />
{/* ... */}
</>
)}
/>
</div>
);
}
Per aggiornare solo l’elemento che è stato modificato, nella funzione onTodoItemUpdate, chiamiamo map sull’Array che rappresenta la lista, come abbiamo già visto nella sezione in cui parlavamo di Array e oggetti negli stati. Utilizziamo l’ID univoco id per distinguere l’elemento modificato dagli altri della lista, visto che tutte le altre proprietà (description e isDone) possono cambiare. Se l’elemento che abbiamo in mano durante l’operazione di map è proprio quello che è stato modificato, allora lo rimpiazziamo con la versione modificata, altrimenti teniamo la versione attuale.
Aprendo l’inspector del browser, possiamo vedere come l’elemento che corrisponde al nostro componente Modal non sia figlio di TodoList, ma figlio dell’elemento <body />, come ci aspettavamo:
[caption id="attachment_33176" align="aligncenter" width="800"] Modal[/caption]
Questo significa che, anche se dovessimo associare position: relative; (o qualsiasi altro valore che non sia static) all’elemento .TodoList o a qualunque genitore dell’elemento, il nostro elemento .Modal rimarrebbe visibile e continuerebbe a coprire il resto della pagina, come è giusto che sia.
15
Stati globali in React
15.1
Introduzione agli stati globali in React
Torniamo alle origini con le proprietà e gli eventi.
Abbiamo visto, ormai, davvero moltissimi esempi del passaggio dei dati in React: i dati viaggiano sempre in una direzione, dai componenti genitori ai figli. I valori dei dati viaggiano tramite props, mentre gli eventi vanno nella direzione opposta. Molto spesso i valori coincidono con degli stati e gli eventi chiamano funzioni setter.
Ci sono dei casi in cui lo stato e la funzione setter vengono definiti a un livello molto diverso da quello in cui vengono usati, nel senso che, tra il componente che definisce lo stato e quello che ne mostra il valore o ne notifica i cambiamenti, ci sono componenti figli di figli di figli di figli…
Prop drilling in React
Nei casi appena descritti si parla di prop drilling (“trapanamento” di proprietà). Noi sappiamo che dobbiamo cercare di spostare la logica (i nostri componenti controller) il più possibile vicino ai componenti che la utilizzano (i componenti view). Ma questa cosa non è sempre fattibile e, soprattutto, alcuni sviluppatori preferiscono concentrare tutta la logica in un unico punto: se uno di questi sviluppatori dovesse essere il nostro capo e non potessimo convincerlo a fare altrimenti, dovremmo adottare anche noi lo stesso approccio.
Nota: la strategia di avere tanti componenti controller e di metterli il più possibile vicino ai loro componenti view non è una questione di preferenze e opinioni personali. Un’applicazione che ha - estremizzando- un solo componente controller che contiene tutta la logica, conterrà difficilmente componenti riutilizzabili. Inoltre, per via del funzionamento di React, ogni cambiamento di stato causerà il rendering dell’intera applicazione. È vero che i componenti view sono, genericamente, più riutilizzabili dei componenti controller, per cui un’applicazione che contiene più componenti view (come quella che ha un unico controller) dovrebbe avere più componenti riutilizzabili, ma difficilmente è così. È molto più probabile che, per gestire il funzionamento delle varie parti del sistema, i componenti view diventino comunque specializzati, finendo per ottenere l’effetto contrario. Se dovessi trovarti ad avere a che fare con componenti che hanno nomi legati a concetti, come UserAgeInput, probabilmente staresti per avere proprio questo problema. Dall’altro lato, progettando il sistema per dividere la logica in più punti, si ha a che fare con funzionalità più semplici ed è più facile individuare parti in comune. Facendo uso delle funzioni hook per isolare e riutilizzare la logica, resteranno solo i dati specifici, come nel caso del nostro componente TodoItemForm, e componenti view legati al tipo di dato da gestire, con nomi come NonNegativeIngeterInput.
Il problema del prop drilling avviene anche quando si utilizzano concetti molto generici, come, per esempio, il tema dell’applicazione (che sia chiaro/scuro, o più personalizzabile), la lingua su cui si basano tutte le traduzioni, o una configurazione che un utente può scegliere e che modifica il comportamento dell’intera applicazione.
In questi casi, se utilizzassimo solo gli strumenti che abbiamo visto fino ad ora, ci troveremmo a passare prop ed eventi attraverso la struttura dei componenti, per usarli magari nei componenti view in fondo alla struttura.
Per esempio, la lingua scelta dall’utente potrebbe servirci attraverso tutta l’applicazione, ma l’evento che segnala il cambiamento della lingua potrebbe essere scatenato da un menù a tendina in una sezione molto specifica dell’applicazione. In questo caso ci troveremmo ad avere a che fare con una struttura del genere:
<App>
<Router language={language} onLanguageChange={onLanguageChange}>
<ProfilePage language={language} onLanguageChange={onLanguageChange}>
<Settings language={language} onLanguageChange={onLanguageChange}>
<Panel language={language} onLanguageChange={onLanguageChange}>
<Select value={language} onChange={onLanguageChange} />
</Panel>
</Settings>
</ProfilePage>
</Router>
</App>
In questo esempio abbiamo un ipotetico stato che rappresenta la lingua scelta dall’utente dichiarato dentro App:
// Nel componente App
const [language, setLanguage] = useState(/* ... */);
Il menù per scegliere la lingua è dentro un componente Select, figlio di Panel, figlio di Settings, figlio di ProfilePage, figlio di Router, figlio di App. Per far arrivare il nostro valore dello stato language e la funzione onLanguageChange che chiama la funzione setter setLanguage, dobbiamo passare entrambi come props per tutti i componenti intermedi! Alcuni di quei componenti potrebbero usare il valore dello stato language, ma nessuno di loro userà onLanguageChange.
Se immaginiamo questa situazione ripetersi per tutte le prop che ci servono un po’ dappertutto, è facile capire come ci si potrebbe ritrovare con componenti controller che scambiano molte più prop di quelle che usano, solo perché devono fare la staffetta con i loro genitori per passare props ai figli. Questo è il problema del prop drilling.
React ci permette di risolvere questo problema tramite la funzione hook useContext e il concetto di Context in generale.
15.2
Context in React
Per fare un esempio dell’utilizzo di Context, immaginiamo di voler implementare una funzione di login, per proteggere la nostra applicazione e anche per creare liste di cose da fare legate a utenti diversi.
Implementiamo prima di tutto il form che, grazie alle nostre funzioni hook useForm e useCommand, alla nostra macchina a stati finiti NetworkRequest e ai nostri componenti Form e TextInput, sarà un passeggiata:
// File: src/components/LoginForm.jsx
import "./LoginForm.css";
import { useCommand } from "../hooks/useCommand";
import { useForm } from "../hooks/useForm";
import { nonEmptyStringValidator } from "../validators";
import Form from "./Form";
import TextInput from "./TextInput";
import Panel from "./Panel";
export default function LoginForm({ onLogin }) {
const [loginNetworkRequest, login] = useCommand({
path: "/users/login",
method: "POST",
});
const { inputProps, onSubmit } = useForm({
fields: {
email: {
initialValue: "",
validator: nonEmptyStringValidator("The email cannot be empty!"),
},
password: {
initialValue: "",
validator: nonEmptyStringValidator("The password cannot be empty!"),
},
},
submit: (data) => {
login(data).then((response) => {
onLogin(response);
});
},
});
return (
<div className="LoginForm">
<Panel>
<Form onSubmit={onSubmit} isLoading={loginNetworkRequest.isLoading()}>
<TextInput
{...inputProps("email")}
placeholder="Email"
type="email"
/>
<TextInput
{...inputProps("password")}
placeholder="Password"
type="password"
/>
</Form>
</Panel>
</div>
);
}
Non abbiamo creato un validatore isEmail, ma passando type="email" a TextInput - che ci permette di inviare attributi direttamente all’elemento <input /> tramite props - sfruttiamo la validazione nativa del browser. Non è una soluzione particolarmente elegante, ma è utile ai fini del nostro esempio.
Il resto è un misto di useCommand e NetworkRequest, useForm e il componente Form. Scateniamo la potenza dei componenti riutilizzabili e delle funzioni hook per sviluppare un intero componente con validazione, invio e sincronizzazione dello stato della richiesta di rete con l’interfaccia. Ci abbiamo addirittura messo Panel, il nostro componente view per eccellenza, per prendere una scorciatoia sull’aspetto visivo.
Poche righe di linguaggio CSS et.. voilà:
/* File: src/components/LoginForm.css */
.LoginForm .TextInput + .TextInput,
.LoginForm button {
margin-top: 0.5em;
}
La prop onLogin comunica al componente controller di LoginForm che il login è avvenuto con successo. Possiamo collegare questa prop al nostro componente App:
// File: src/components/App.jsx
// ...
import LoginForm from "./components/LoginForm";
import { useState } from "react";
// ...
export default function App() {
const [isLoggedIn, setIsLoggedIn] = useState(false);
// ...
if (isLoggedIn) {
return {
/* ... */
};
} else {
const onLogin = () => {
setIsLoggedIn(true);
};
return <LoginForm onLogin={onLogin} />;
}
}
Aggiungiamo uno stato al nostro componente App, che traccia lo stato di autenticazione dell’utente. Con il rendering condizionale, mostriamo la lista di cose da fare all’utente autenticato e il form a quello anonimo.
Ora, immaginiamo di voler inserire un bottone “Logout” all’interno del componente TodoList. Dovremmo passare una funzione logout come prop a TodoList e chiamarla al click del bottone, all’interno del componente. In questo caso, questo passaggio non sarebbe un grosso problema ma, come dicevamo prima, alla lunga, con l’aggiunta di livelli di profondità e di funzionalità, la cosa potrebbe sfuggire di mano.
Proviamo. allora. a utilizzare Context.
Come funziona Context?
Context (che, letteralmente, vuol dire “contesto”) funziona a due livelli: il Provider (la cui traduzione è “fornitore”) e il/i Consumer (tradotto, appunto, “consumatore”).
Ogni Context ha un solo Provider, ma può avere più di un Consumer.
Il Provider di un Context contiene solitamente uno stato.
Tutti i Consumer accedono allo stesso stato.
Per il resto, Context funziona come il resto in React: lo stato viene passato ai Consumer e i cambiamenti vengono segnalati tramite eventi.
Cominciamo dal Provider.
Possiamo creare una nuova cartella contexts e inserire un nuovo file AuthContext.jsx al suo interno. L’estensione jsx serve perché il Provider esporta un componente React:
// File: src/contexts/AuthContext.jsx
import { createContext, useContext, useState } from "react";
const AuthContextStatus = {
anonymous: "anonymous",
loggedIn: "loggedIn",
};
const AuthContext = createContext({
isLoggedIn: () => false,
login: () => {},
logout: () => {},
});
export function AuthContextProvider({ children }) {
const [authStatus, setAuthStatus] = useState({
status: AuthContextStatus.anonymous,
});
const login = () => {
setAuthStatus({
status: AuthContextStatus.loggedIn,
});
};
const logout = () => {
setAuthStatus({
status: AuthContextStatus.anonymous,
});
};
const isLoggedIn = () => {
return authStatus.status === AuthContextStatus.loggedIn;
};
return (
<AuthContext.Provider value={{ isLoggedIn, login, logout }}>
{children}
</AuthContext.Provider>
);
}
export function useAuthContext() {
return useContext(AuthContext);
}
Ci sono un bel po’ di cose nuove in questo file, vediamole per passi:
// File: src/contexts/AuthContext.jsx
// ...
const AuthContext = createContext({
isLoggedIn: () => false,
login: () => {},
logout: () => {},
});
// ...
createContext è la funzione fornita da React che ci permette di creare il Context, lo “stato globale”. createContext accetta un solo argomento, che rappresenta il valore predefinito dello stato del Context di riferimento (nel nostro caso AuthContext). Possiamo associare l’argomento accettato da createContext a quello accettato da useState, un valore iniziale.
A differenza dell’argomento accettato da useState, però, il valore predefinito passato a createContext non verrà utilizzato, perché verrà sostituito dal valore che daremo alla prop value di AuthContext.Provider prima che il Context venga utilizzato per la prima volta.
Il motivo per cui createContext accetta un valore iniziale è legato a TypeScript, non siamo costretti a passare un valore iniziale. Farlo, però, ci aiuta a prendere nota di che forma vogliamo che abbia il nostro Context, e aiuta anche chi legge il nostro codice a capirlo senza dover cercare il punto in cui creiamo il Provider. Se vuoi approfondire, ecco un’interessante discussione con l’intervento del team di sviluppo di React.
Dall’interno, AuthContext avrà uno status e due eventi, login e logout. Possiamo facilmente immaginare lo schema.
[caption id="attachment_33179" align="aligncenter" width="300"] FSM - Autenticazione[/caption]
status rappresenta lo stato (“anonimo” o “autenticato”), login e logout rappresentano gli eventi che scatenano le transizioni.
All’esterno, invece, non restituiamo lo status, che ha valori gestiti internamente, ma una funzione isLoggedIn che restituisce true se l’utente è autenticato, false se è anonimo.
Diamo al valore predefinito una forma simile a quella che avrà il valore effettivo di AuthContext. isLoggedIn restituisce false, per ricordarci che è una funzione che restituisce un valore booleano, e anche che lo stato iniziale della macchina a stati finiti sarà “anonimo”.
login e logout saranno due funzioni che non accetteranno nessun argomento e non restituiranno nessun valore, perché il loro compito sarà scatenare le transizioni della macchina a stati finiti e il risultato delle transizioni sarà un cambiamento di status e, di conseguenza, del valore di ritorno di isLoggedIn.
Infine, creiamo un oggetto AuthContextStatus per aiutarci a ricordare i valori che lo status può assumere.
Nota: in un’applicazione realistica, login accetterebbe come argomento le credenziali dell’utente, come il token di autenticazione (mai utilizzare la password!). status, nella sua forma "loggedIn", avrebbe a sua volta le credenziali come proprietà.
// File: src/contexts/AuthContext.jsx
// ...
export function AuthContextProvider({ children }) {
const [authStatus, setAuthStatus] = useState({
status: AuthContextStatus.anonymous,
});
const login = () => {
setAuthStatus({
status: AuthContextStatus.loggedIn,
});
};
const logout = () => {
setAuthStatus({
status: AuthContextStatus.anonymous,
});
};
const isLoggedIn = () => {
return authStatus.status === AuthContextStatus.loggedIn;
};
// ...
}
// ...
La prima parte di AuthContextProvider non è diversa da quella che potremmo avere con qualsiasi altro componente. authStatus è lo stato del componente. Il suo stato iniziale è “anonymous” perché, quando l’applicazione è visualizzata la prima volta, non sappiamo chi sia l’utente.
Nota: se volessimo salvare e leggere le credenziali dell’utente nell’archivio locale del browser, con useEffect, window.localStorage e gli esempi che abbiamo visto in precedenza, questo sarebbe il punto perfetto per farlo. Le credenziali rimarrebbero isolate all’interno di AuthContextProvider e di semplice accesso per qualsiasi componente attraverso l’uso di AuthContext, senza chiedersi da dove vengono.
login e logout sono due funzioni che gestiscono eventi, che corrispondono agli eventi che scatenano le transizioni della macchina a stati finiti. Le due funzioni chiamano la funzione setter setAuthStatus, causando un ciclo di rendering, gestendo il flusso di autenticazione in perfetto stile React.
// File: src/contexts/AuthContext.jsx
// ...
export function AuthContextProvider({ children }) {
// ...
return (
<AuthContext.Provider value={{ isLoggedIn, login, logout }}>
{children}
</AuthContext.Provider>
);
}
// ...
Questa è, forse, la parte più specifica rispetto al concetto di Context. Per permettere ai componenti di accedere a un Context, i componenti devono essere figli del componente Provider legato a quel Context. Tutte le istanze di Context, come il nostro AuthContext, sono degli oggetti con una proprietà Provider che è un componente. Provider accetta una prop - value - che contiene lo stato corrente del Context.
React tratta il nostro Provider come qualsiasi altro componente: quando la prop value viene aggiornata, tutti i figli del component Provider vengono chiamati al rendering.
Notiamo le doppie parentesi graffe in value={{ isLoggedIn, login, logout }}. Come abbiamo già visto in una delle prime sezioni, con l’attributo style, la coppia di parentesi graffe esterne serve per passare da JSX a JavaScript, mentre la coppia di parentesi graffe interne rappresenta il fatto che stiamo passando un oggetto come prop.
La chiamata risultante, per utilizzare l’oggetto che abbiamo passato alla prop value, è la seguente:
const { isLoggedIn, login, logout } = useContext(AuthContext);
Lo stesso oggetto che entra nella prop value esce dalla chiamata alla funzione hook useContext. Noi, però, aggiungeremo un ulteriore livello di astrazione.
Context e la separazione dei concetti
// File: src/contexts/AuthContext.jsx
// ...
export function useAuthContext() {
return useContext(AuthContext);
}
useContext è la funzione hook che permette a qualsiasi componente figlio del Provider di accedere allo stato del Context. Riprendendo l’esempio iniziale:
<LanguageContext.Provider value={{ language, onLanguageChange }}>
<App>
<Router>
<ProfilePage>
<Settings>
<Panel>
<Select />
</Panel>
</Settings>
</ProfilePage>
</Router>
</App>
</LanguageContext.Provider>
Grazie all’utilizzo di un ipotetico LanguageContext, il componente Select potrebbe utilizzare questa sintassi:
// ...
export default function Select(/* ... */) {
// ...
const { language, onLanguageChange } = useContext(LanguageContext);
// ...
}
15.3
Quando evitare l'uso di Context in React
Quando si parla di Context, c’è un classico caso d’uso problematico che andrebbe evitato: vediamo quale e perché.
Normalmente, l’utilizzo di Context si divide in tre passi:
La chiamata a createContext.
La creazione del Provider.
Le chiamate a useContext.
Due classici errori che si fanno quando si utilizza Context sono:
Non passare un valore di default a createContext.
Esportare l’istanza di Context.
Per esempio, viene creata l’istanza di Context in un file (senza valore di default):
import { createContext } from "react";
export const AuthContext = createContext();
E, poi, viene importata in un file separato per l’utilizzo del Provider:
import { AuthContext } from "./AuthContext";
export default function AuthContextProvider({ children }) {
// ...
return (
<AuthContext.Provider
value={
{
/* ... */
}
}
>
{children}
</AuthContext.Provider>
);
}
Infine, vengono utilizzati Context e Provider importati dai due file diversi:
import { AuthContext } from "../contexts/AuthContext";
import { AuthContextProvider } from "../contexts/AuthContextProvider";
export default function SomeComponent() {
const { isLoggedIn, login, logout } = useContext(AuthContext);
return <AuthContextProvider>{/* ... */}</AuthContextProvider>;
}
Questi due errori portano a due problemi:
L’assenza di un valore predefinito passato createContext rende più difficile a chi vorrebbe usarlo capire che tipo di funzionalità sono previste. Se il Provider, poi, è definito in un file separato, la ricerca diventa ancora più frustrante.
Esportare l’istanza di Context significa renderlo disponibile non solo per l’utilizzo da Consumer, ma anche per l’utilizzo da Provider. Chiunque potrebbe creare un secondo AuthContextProvider o, peggio ancora, utilizzare direttamente AuthContext.Provider in un componente, generando il caos.
Ecco perché non abbiamo esportato AuthContext. Non avendolo esportato, è impossibile utilizzare questa sintassi in un componente:
const { isLoggedIn, login, logout } = useContext(AuthContext);
// ^^^^^^^^^^^
// irraggiungibile!
Ecco perché esportiamo una funzione hook apposita, useAuthContext:
export function useAuthContext() {
return useContext(AuthContext);
}
Come ormai sai bene, le funzioni hook sono funzioni che hanno un nome che inizia per use e possono usare altre funzioni hook. useAuthContext ha un nome che inizia per use e, di conseguenza, può utilizzare la funzione hook useContext fornita da React.
A questo punto, non ci resta che fare due cose:
Inserire il componente AuthContextProvider.
Utilizzare useAuthContext.
Useremo useAuthContext in App, perché è lì che gestiamo l’autenticazione. Di conseguenza, AuthContextProvider dovrà essere un componente genitore di App, perché possiamo usare un Context solo se il componente che lo usa è figlio del Provider di quel Context. L’unico punto in cui possiamo inserire componenti genitori di App è index.js (o index.jsx, se hai usato Vite).
// File: src/index.js(x)
// ...
import { AuthContextProvider } from "./contexts/AuthContext";
const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(
<React.StrictMode>
<AuthContextProvider>
<App />
</AuthContextProvider>
</React.StrictMode>
);
// ...
Ora possiamo usare useAuthContext in App:
// File: src/App.jsx
// ...
import { useAuthContext } from "./contexts/AuthContext";
// ...
export default function App() {
const { isLoggedIn, login } = useAuthContext();
// ...
if (isLoggedIn()) {
return <div className="App">{/* ... */}</div>;
} else {
return <LoginForm onLogin={login} />;
}
}
Ora, se volessimo inserire un bottone “Logout” in un componente, ci basterebbe scrivere una cosa del genere:
export default function AnyComponent() {
const { logout } = useAuthContext();
return (
{/* ... */}
<button onClick={logout}>Logout</button>
{/* ... */}
)
}
Il fatto di non aver esportato AuthContext ci permette di legarlo al concetto di autenticazione dell’utente, esponendo solo le funzionalità che servono. Come nel caso delle funzioni hook, Context è un modo per isolare funzionalità e concetti. AuthContext gestisce internamente il flusso di autenticazione attraverso una macchina a stati finiti ed espone la possibilità di leggerne lo stato e scatenarne le transizioni.
I pericoli dell’uso di Context in React
L’utilizzo di Context, come abbiamo visto, è estremamente comodo per evitare il problema del prop drilling. Come sempre, però, essendo una cosa che esula dal normale comportamento di React (prop, eventi e stati) è uno strumento da usare con moderazione.
Un utilizzo eccessivo di Context - per esempio usandolo anche solo per evitare il passaggio di prop da un componente al foglio del figlio - potrebbe portare a questi problemi:
Eccessiva frammentazione della logica: Context andrebbe usato per isolare concetti indipendenti, come l’autenticazione dell’utente, il tema grafico dell’applicazione o una configurazione globale. Usarlo solo come strumento per evitare il prop drilling potrebbe distrarti dal suo vero scopo e portarti a creare Context legati a pezzi di stato dell’applicazione, come una pagina specifica, un form, una chiamata di rete.
Diminuzione della riusabilità dei componenti: i Provider sono dei componenti controller che controllano componenti view su più livelli, condividendone lo stato. Questo significa che ogni componente Consumer, oltre a essere legato alle prop, è legato anche al Provider. Se volessimo spostare un componente del genere da un’applicazione a un’altra, oltre a collegarne le prop, dovremmo anche ricreare le istanze Context corrispondenti, oppure modificare il componente per smettere di utilizzare le istanze di Context.
Problemi di Performance: sappiamo che, ogni volta che un componente cambia stato, React chiama tutti i suoi discendenti al rendering. I Provider sono normalmente utilizzati a un livello molto vicino alla radice dell’applicazione (il nostro componente App) o, come nel nostro caso, a un livello ancora più estero (index.js(x)). Di conseguenza, il cambio dello stato di un’istanza di Context scatena normalmente grandi cicli di rendering, mettendo a dura prova le performance di React.
Occhio, quindi, a non esagerare!
16
Reference in React
16.1
Che cos’è una reference in React
Nel corso delle varie sezioni, abbiamo citato un paio di volte il concetto di ref: una volta per dire che, insieme a key e children, è un attributo valido per ogni elemento o componente React; un’altra volta quando si parlava dei valori che sopravvivono (cioè non vengono ricalcolati) ai cicli di rendering di React.
In React, la parola reference descrive un valore che viene mantenuto in memoria tra un ciclo di rendering e l’altro, e il cui cambiamento non scatena un nuovo ciclo di rendering.
React ci fornisce una funzione hook, che si chiama useRef (con ref che abbrevia, appunto, reference) e che ci permette di generare un valore di questo tipo. Cominciamo con la sintassi:
const myRef = useRef(initialValue);
In modo analogo a (uno dei due usi di) useState, inizializziamo il riferimento passando a useRef il suo valore iniziale. A differenza di useState, però, useRef non restituisce un array ma un oggetto, con una sola chiave che ha nome current e contiene il valore iniziale:
const myRef = useRef(0);
/*
myRef === {
current: 0
}
*/
Perché questo strato di complessità? In linguaggio JavaScript, numeri, stringhe, booleani, null e undefined, quando passano da una variabile all’altra, vengono clonati all’interno della memoria. Array, oggetti e funzioni, diversamente, vengono salvati in memoria con un sistema più complesso e viene loro assegnato un indirizzo di memoria (un puntatore) che permette al processore di recuperarli. Non sapendo cosa i riferimenti conterranno, React inserisce il loro valore in un oggetto, così che non venga mai clonato dalla memoria, causando eventuali imprevisti.
Che cos’è una ref in React
In sostanza, i riferimenti rappresentano la mutabilità in React. Abbiamo visto come, in programmazione funzionale, per assicurare l’assenza degli effetti collaterali, si vieta la mutabilità (come l’uso di let, l’aggiornamento di elementi di un array o dei valori di un oggetto). Per i casi in cui la mutabilità è richiesta, React prevede i riferimenti.
Infatti, mentre utilizziamo le funzioni setter per dare in pasto a React il “prossimo” stato, possiamo aggiornare i riferimenti direttamente, per esempio:
myRef.current = 42;
Essendo i componenti di React delle funzioni pure, i riferimenti non possono far parte del ciclo di rendering. Permettendo la mutabilità, i riferimenti devono necessariamente rimanere fuori dal contesto di React, infatti:
I riferimenti rimangono identici tra un ciclo di rendering e l’altro: React non li include nella fase di “ricalcolo dell’interfaccia”.
Modificare il valore di un riferimento non scatena un ciclo di rendering: React non si occupa di sincronizzarne l’aggiornamento con gli stati.
17
Ottimizzazione delle applicazioni React
17.1
Le domande da porsi per ottimizzare il codice in React
In quest’ultimo passo del nostro viaggio, parliamo di ottimizzazione delle applicazioni React.
Quando si tratta di front-end, l’attore più lento in ogni processo dovrebbe essere sempre l’utente, l’essere umano che sta interagendo con l’interfaccia. Molto spesso questa cosa è semplice da ottenere, perché i computer sono molto, molto più veloci degli esseri umani.
Quando un’applicazione diventa molto grande, però, può capitare che alcune delle sue parti diventino più lente della persona che le sta usando. Come abbiamo visto in precedenza, i livelli di astrazione che semplificano il ragionamento dei programmatori informatici, peggiorano le performance dei computer.
Le domande da porsi per ottimizzare il codice
Il primo approccio all’ottimizzazione del codice non ha mai a che fare con il codice. Prima di pensare di ottimizzare il codice, possiamo porci una serie di domande che possono portare a risultati ben migliori di quelli che porterebbe codice ottimizzato, intervenendo sulle persone:
1) L’applicazione sta facendo solo ed esclusivamente quello che deve fare, oppure fa altre cose che non servono?
Le cose che non servono hanno, di solito, a che fare con:
Requisiti che non sono più utili: funzionalità che nessuno usa, o molto pochi utenti usano, il cui codice non è mai stato eliminato.
Operazioni che sono state delegate al front-end ma potrebbero essere fatte in altri punti dello stack - come il back-end o il database - che sono più ottimizzati per farle. Classici esempi sono la gestione di cache e la paginazione di contenuti, oppure quei casi in cui il backend invia i dati con una struttura completamente diversa da quella che serve al front-end. In questi casi, a seconda del linguaggio di programmazione con cui è scritto il back-end, della potenza del server su cui è installato e dell’esperienza di chi lo sviluppa, potrebbe avere senso spostare calcoli e trasformazioni del formato dei dati sul back-end o sul database.
Correzione di errori generati da altre parti del sistema: problemi che non sono mai stati risolti, ma quando ne vengono individuati i sintomi vengono corretti sul posto. Per esempio, nelle applicazioni React che usano impropriamente gli effetti collaterali, è facile che gli stati e i cicli di rendering siano desincronizzati. A volte, per mancanza di tempo o semplicemente perché si è perso il controllo degli intrecci di effetti scatenati da altri effetti, la “soluzione” è aggiungere ancora più codice per forzare la sincronizzazione in caso di stati incoerenti o incompatibili.
Eliminare il codice di troppo, rivedere i componenti più complessi e spostare la logica nei punti giusti dello stack può aiutare a ottimizzare il sistema.
2) L’applicazione sta rappresentando la realtà in modo coerente?
Molto spesso, soprattutto quando si implementano molte cose nuove molto velocemente, non si riesce ad avere una visione di insieme di quello che si sta rappresentando.
Questo può portare ad avere strutture di dati molto più complesse di quello che serve, perché costruite “a pezzi” aggiungendo informazioni a quello che si pensava fosse la rappresentazione della realtà.
Svuotare la mente dalle strutture dei dati e tutti i casi possibili che bisogna coprire, per riprendere in considerazione il sistema nel suo intero e la sua rappresentazione, può essere molto utile per “snellire” l’applicazione.
L’altra cosa che aiuta moltissimo in questo caso - e andrebbe fatta a partire dalla prima riga di codice quando l’applicazione è ancora vuota - è il testing (unit testing e end-to-end testing).
3) Ci sono funzionalità che è possibile astrarre e isolare?
Come abbiamo visto con molteplici esempi, isolare funzionalità indipendenti all’interno di funzioni hook ci aiuta ad avere solo:
Componenti che contengono solo chiamate a funzioni “compilate” con gli argomenti relativi al caso specifico, come nel caso di useForm a cui passiamo solo i nomi dei campi e le funzioni di validazione che abbiamo già a disposizione.
Componenti che contengono solo rappresentazioni grafiche delle strutture dati che viaggiano all’interno dell’applicazione, come nel caso di Query che gestisce qualsiasi chiamata di rete di tipo query.
Raggiungere uno stato del genere richiede tanto ragionamento astratto e una buona dose di esperienza ma, una volta raggiunto l’obiettivo, è incredibilmente semplice individuare e misurare le performance delle singole funzionalità, per trovare eventuali punti deboli.
17.2
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 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à:
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,
});
// ...
}
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:
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.
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.
17.3
I pericoli delle ottimizzazioni in React
Come abbiamo appena visto, tutte le funzioni hook che richiedono una lista di dipendenze in React (useEffect, useMemo e useCallback, per esempio) creano un albero di dipendenze che può diventare molto facilmente un ammasso di costrizioni auto-imposte.
Per ogni uso delle funzioni hook che richiedono una lista di dipendenze, siamo costretti a prendere in considerazione tutte le dipendenze. Sappiamo che qualsiasi costante, variabile o funzione che non è generata da una funzione hook di questo tipo sarà ricalcolata per ogni ciclo di rendering e possiamo intuire che, se anche una sola delle dipendenze passate a una di queste funzioni hook è ricalcolata per ogni ciclo di rendering, allora l’intera catena di chiamate alle funzioni hook sarà a sua volta fatta per ogni ciclo di rendering, rendendo l’uso delle funzioni hook praticamente inutile.
Questo significa che, ogni volta che decidiamo di usare una funzione hook che richiede una lista di dipendenze, dobbiamo poi “inseguire” tutte le dipendenze per chiuderle a loro volta in una chiamata che richiede dipendenze, e così via. Questo può facilmente portare a “inseguire” dipendenze nei componenti genitori, e nei componenti genitori dei componenti genitori, fino a creare una rete inestricabile di dipendenze che impazziremmo per tenere sotto controllo.
Quando ha senso, allora, utilizzare i metodi di ottimizzazione dei cicli di rendering?
Utilizzare i metodi di ottimizzazione dei cicli di rendering ha senso solo quando non abbiamo controllo su altre parti del sistema che potrebbero risolvere meglio il problema (la maggior parte delle volte, il back-end e il database) e nel modo più isolato e ristretto possibile. Il concetto non dista molto da quello di effetto collaterale: evitiamolo se possiamo, altrimenti isoliamolo, così che sia facile controllarlo.
Questo discorso vale per useMemo e useCallback, mentre memo e lazy sono molto meno pericolosi, soprattutto se utilizzati con componenti molto in alto rispetto all’albero, quelli con molti discendenti.
Se la nostra guida React ha stuzzicato il tuo interesse e vuoi approfondire l'apprendimento di questa libreria, iscriviti alla nostra masterclass React!
CONTENUTI GRATUITI IN EVIDENZA
Guide per aspiranti programmatori 👨🏻🚀
Vuoi muovere i primi passi nel Digital e Tech? Abbiamo preparato alcune guide per aiutarti a orientarti negli ambiti più richiesti oggi.