Aggiornare gli Oggetti nello State
Lo State può contenere qualsiasi tipo di valore JavaScript, inclusi gli oggetti. Tuttavia, non dovresti modificare direttamente gli oggetti contenuti nello State. Quando vuoi aggiornare un oggetto, dovresti invece crearne uno nuovo (o copiare quello esistente) e, infine, impostare la copia nello state.
Imparerai
- Come aggiornare correttamente un oggetto nello state di React
- Come aggiornare un oggetto nidificato senza mutarlo
- Cos’è l’immutabilità, e come non violarla
- Come rendere la copia di oggetti meno ripetitiva con Immer
Cos’è una mutazione?
Nello state, puoi memorizzare qualsiasi tipo di valore JavaScript.
const [x, setX] = useState(0);
Fino a ora, hai lavorato con numeri, stringhe, e booleani. Questi tipi di valori JavaScript sono “immutabili”, cioè non modificabili o “di sola lettura”. Puoi triggerare una ri-renderizzazione per sostituire un valore:
setX(5);
Lo state x
è cambiato da 0
a 5
, ma il numero 0
in sé non è cambiato. Non è possibile apportare modifiche ai valori primitivi incorporati come numeri, stringhe e booleani in JavaScript.
Considera ora un oggetto nello state:
const [position, setPosition] = useState({ x: 0, y: 0 });
Tecnicamente, è possibile modificare il contenuto dell’oggetto in sé. Questa è detta mutazione:
position.x = 5;
Tuttavia, nonostante gli oggetti nello state di React siano tecnicamente mutabili, dovresti trattarli come se fossero immutabili, come numeri, booleani e stringhe. Invece di mutarli, dovresti sempre sostituirli.
Tratta lo state come se fosse di sola lettura
In altre parole, dovresti trattare qualsiasi oggetto JavaScript che metti nello state come se fosse di sola lettura.
Questo esempio conserva un oggetto nello state per rappresentare la posizione attuale del puntatore. Il punto rosso dovrebbe muoversi quando tocchi o muovi il cursore sull’area di anteprima. Tuttavia, il punto rimane nella posizione iniziale:
import { useState } from 'react'; export default function MovingDot() { const [position, setPosition] = useState({ x: 0, y: 0 }); return ( <div onPointerMove={e => { position.x = e.clientX; position.y = e.clientY; }} style={{ position: 'relative', width: '100vw', height: '100vh', }}> <div style={{ position: 'absolute', backgroundColor: 'red', borderRadius: '50%', transform: `translate(${position.x}px, ${position.y}px)`, left: -10, top: -10, width: 20, height: 20, }} /> </div> ); }
Il problema è in questa parte di codice.
onPointerMove={e => {
position.x = e.clientX;
position.y = e.clientY;
}}
Questo codice modifica l’oggetto assegnato a position
dalla renderizzazione precedente. Ma senza utilizzare la funzione d’impostazione dello state, React non ha idea che l’oggetto sia cambiato. Quindi, React non fa nulla in risposta. È come cercare di cambiare l’ordine dopo aver già mangiato il pasto. Sebbene mutare lo state possa funzionare in alcuni casi, non lo consigliamo. Dovresti trattare il valore dello state a cui hai accesso in una renderizzazione come se fosse di sola lettura.
Per triggerare una ri-renderizzazione in questo caso, crea un nuovo oggetto e passalo alla funzione d’impostazione dello state:
onPointerMove={e => {
setPosition({
x: e.clientX,
y: e.clientY
});
}}
Con setPosition
, stai dicendo a React:
- Sostituisci
position
con questo nuovo oggetto - E renderizza nuovamente questo componente
Nota come il punto rosso ora segue il tuo puntatore quando tocchi o muovi il mouse sopra l’area di anteprima:
import { useState } from 'react'; export default function MovingDot() { const [position, setPosition] = useState({ x: 0, y: 0 }); return ( <div onPointerMove={e => { setPosition({ x: e.clientX, y: e.clientY }); }} style={{ position: 'relative', width: '100vw', height: '100vh', }}> <div style={{ position: 'absolute', backgroundColor: 'red', borderRadius: '50%', transform: `translate(${position.x}px, ${position.y}px)`, left: -10, top: -10, width: 20, height: 20, }} /> </div> ); }
Approfondimento
Codice come questo è problematico perché modifica un oggetto esistente nello state:
position.x = e.clientX;
position.y = e.clientY;
Tuttavia codice come questo è assolutamente valido perché stai mutando un oggetto che hai appena creato:
const nextPosition = {};
nextPosition.x = e.clientX;
nextPosition.y = e.clientY;
setPosition(nextPosition);
Difatti, equivale del tutto a scrivere questo:
setPosition({
x: e.clientX,
y: e.clientY
});
Mutare è un problema solo quando modifichi oggetti esistenti che sono già nello state. Mutare un oggetto che hai appena creato va bene perché nessun altro codice lo utilizza ancora. Modificarlo non impatterà accidentalmente qualcosa che dipende da esso. Questa si definisce come “mutazione locale”. Puoi persino mutare localmente durante la renderizzazione. Molto comodo e perfettamente valido!
Copiare gli oggetti con la sintassi di spread
Nell’esempio precedente, l’oggetto position
è creato sempre da zero a partire dalla posizione del puntatore attuale. Tuttavia, spesso vorrai includere dati esistenti nel nuovo oggetto che stai creando. Ad esempio, potresti voler aggiornare solo un campo in un form, ma mantenere i valori precedenti per tutti gli altri campi.
Questi input non funzionano perché gli handler degli onChange
mutano lo state:
import { useState } from 'react'; export default function Form() { const [person, setPerson] = useState({ firstName: 'Barbara', lastName: 'Hepworth', email: 'bhepworth@sculpture.com' }); function handleFirstNameChange(e) { person.firstName = e.target.value; } function handleLastNameChange(e) { person.lastName = e.target.value; } function handleEmailChange(e) { person.email = e.target.value; } return ( <> <label> First name: <input value={person.firstName} onChange={handleFirstNameChange} /> </label> <label> Last name: <input value={person.lastName} onChange={handleLastNameChange} /> </label> <label> Email: <input value={person.email} onChange={handleEmailChange} /> </label> <p> {person.firstName}{' '} {person.lastName}{' '} ({person.email}) </p> </> ); }
Ad esempio, questa riga muta lo state di una renderizzazione precedente:
person.firstName = e.target.value;
Il modo affidabile per ottenere il comportamento voluto è di creare un oggetto nuovo e passarlo a setPerson
. Qui però, dovresti anche copiare i dati esistenti in esso perché solo uno dei campi è cambiato:
setPerson({
firstName: e.target.value, // Nuovo valore dall'input
lastName: person.lastName,
email: person.email
});
Puoi usare la sintassi spread degli oggetti ...
in modo da non dover copiare ogni singola proprietà.
setPerson({
...person, // Copia i campi vecchi
firstName: e.target.value // Ma sovrascrivi questo
});
Ora il form funziona!
Nota come non hai dichiarato una variabile state separata per ciascun input. Per i form grandi, conservare tutti i dati raggruppati in un oggetto è molto comodo, purché venga aggiornato correttamente!
import { useState } from 'react'; export default function Form() { const [person, setPerson] = useState({ firstName: 'Barbara', lastName: 'Hepworth', email: 'bhepworth@sculpture.com' }); function handleFirstNameChange(e) { setPerson({ ...person, firstName: e.target.value }); } function handleLastNameChange(e) { setPerson({ ...person, lastName: e.target.value }); } function handleEmailChange(e) { setPerson({ ...person, email: e.target.value }); } return ( <> <label> First name: <input value={person.firstName} onChange={handleFirstNameChange} /> </label> <label> Last name: <input value={person.lastName} onChange={handleLastNameChange} /> </label> <label> Email: <input value={person.email} onChange={handleEmailChange} /> </label> <p> {person.firstName}{' '} {person.lastName}{' '} ({person.email}) </p> </> ); }
Nota che la sintassi di spread ...
è “superficiale”, cioè copia solo il primo livello di profondità. Questo la rende veloce, ma significa anche che se vuoi aggiornare una proprietà nidificata, dovrai usarla più volte.
Approfondimento
Puoi anche usare le parentesi [
e ]
dentro alla definizione dell’oggetto per specificare una proprietà con nome dinamico. Ecco lo stesso esempio, ma con un solo event handler invece di tre:
import { useState } from 'react'; export default function Form() { const [person, setPerson] = useState({ firstName: 'Barbara', lastName: 'Hepworth', email: 'bhepworth@sculpture.com' }); function handleChange(e) { setPerson({ ...person, [e.target.name]: e.target.value }); } return ( <> <label> First name: <input name="firstName" value={person.firstName} onChange={handleChange} /> </label> <label> Last name: <input name="lastName" value={person.lastName} onChange={handleChange} /> </label> <label> Email: <input name="email" value={person.email} onChange={handleChange} /> </label> <p> {person.firstName}{' '} {person.lastName}{' '} ({person.email}) </p> </> ); }
Qui, e.target.name
fa riferimento alla proprietà name
data all’elemento DOM <input>
.
Aggiornare un oggetto nidificato
Considera una struttura di oggetti nidificati come questa:
const [person, setPerson] = useState({
name: 'Niki de Saint Phalle',
artwork: {
title: 'Blue Nana',
city: 'Hamburg',
image: 'https://i.imgur.com/Sd1AgUOm.jpg',
}
});
Se vuoi aggiornare person.artwork.city
, è chiaro come farlo con una mutazione:
person.artwork.city = 'New Delhi';
Ma in React, trattiamo lo state come immutabile! Per modificare city
, dovresti prima produrre il nuovo oggetto artwork
(pre-popolato con i dati di quello precedente), e poi produrre il nuovo oggetto person
che punta al nuovo artwork
:
const nextArtwork = { ...person.artwork, city: 'New Delhi' };
const nextPerson = { ...person, artwork: nextArtwork };
setPerson(nextPerson);
O, scritto come una singola chiamata alla funzione:
setPerson({
...person, // Copia gli altri campi
artwork: { // ma sostituisci artwork
...person.artwork, // con lo stesso
city: 'New Delhi' // ma in New Delhi!
}
});
Questo diventa un po’ verboso, ma funziona bene per molti casi:
import { useState } from 'react'; export default function Form() { const [person, setPerson] = useState({ name: 'Niki de Saint Phalle', artwork: { title: 'Blue Nana', city: 'Hamburg', image: 'https://i.imgur.com/Sd1AgUOm.jpg', } }); function handleNameChange(e) { setPerson({ ...person, name: e.target.value }); } function handleTitleChange(e) { setPerson({ ...person, artwork: { ...person.artwork, title: e.target.value } }); } function handleCityChange(e) { setPerson({ ...person, artwork: { ...person.artwork, city: e.target.value } }); } function handleImageChange(e) { setPerson({ ...person, artwork: { ...person.artwork, image: e.target.value } }); } return ( <> <label> Name: <input value={person.name} onChange={handleNameChange} /> </label> <label> Title: <input value={person.artwork.title} onChange={handleTitleChange} /> </label> <label> City: <input value={person.artwork.city} onChange={handleCityChange} /> </label> <label> Image: <input value={person.artwork.image} onChange={handleImageChange} /> </label> <p> <i>{person.artwork.title}</i> {' by '} {person.name} <br /> (located in {person.artwork.city}) </p> <img src={person.artwork.image} alt={person.artwork.title} /> </> ); }
Approfondimento
Un oggetto come questo appare “nidificato” nel codice:
let obj = {
name: 'Niki de Saint Phalle',
artwork: {
title: 'Blue Nana',
city: 'Hamburg',
image: 'https://i.imgur.com/Sd1AgUOm.jpg',
}
};
Tuttavia, la “nidificazione” è una modo impreciso di pensare al comportamento degli oggetti. Quando il codice viene eseguito, non esiste una cosa come un oggetto “nidificato”. Sono in realtà due oggetti differenti:
let obj1 = {
title: 'Blue Nana',
city: 'Hamburg',
image: 'https://i.imgur.com/Sd1AgUOm.jpg',
};
let obj2 = {
name: 'Niki de Saint Phalle',
artwork: obj1
};
L’oggetto obj1
non è “dentro” obj2
. Ad esempio, anche obj3
potrebbe “puntare” a obj1
:
let obj1 = {
title: 'Blue Nana',
city: 'Hamburg',
image: 'https://i.imgur.com/Sd1AgUOm.jpg',
};
let obj2 = {
name: 'Niki de Saint Phalle',
artwork: obj1
};
let obj3 = {
name: 'Copycat',
artwork: obj1
};
Se dovessi mutare obj3.artwork.city
, questo impatterebbe sia obj2.artwork.city
che obj1.city
. Questo perché obj3.artwork
, obj2.artwork
e obj1
sono lo stesso oggetto. Questo è difficile da capire quando pensi agli oggetti come “nidificati”. Invece, sono oggetti separati che si “puntano” a vicenda tramite le proprietà.
Scrivi logica di aggiornamento concisa con Immer
Se il tuo state è profondamente nidificato, potresti voler considerare di appiattirlo. Ma, se non vuoi modificare la struttura dello state, potresti preferire una scorciatoia rispetto agli spread nidificati. Immer è una popolare libreria che ti consente di scrivere utilizzando la comoda ma mutevole sintassi e si prende cura di produrre le copie per te. Con Immer, il codice che scrivi appare come se stessi “violando le regole” e mutando un oggetto:
updatePerson(draft => {
draft.artwork.city = 'Lagos';
});
Ma a differenza di una mutazione normale, non sovrascrive lo state precedente!
Approfondimento
La bozza
fornita da Immer è uno speciale oggetto, chiamato Proxy, che “registra” cosa fai con esso. Questo è il motivo per cui puoi mutarlo liberamente! Sotto al cofano, Immer capisce quali parti della bozza
sono state modificate, e produce un oggetto completamente nuovo che contiene le tue modifiche.
Per provare Immer:
- Esegui
npm install use-immer
per aggiungere Immer come dipendenza - Poi sostituisci
import { useState } from 'react'
conimport { useImmer } from 'use-immer'
Ecco l’esempio precedente convertito a Immer:
import { useImmer } from 'use-immer'; export default function Form() { const [person, updatePerson] = useImmer({ name: 'Niki de Saint Phalle', artwork: { title: 'Blue Nana', city: 'Hamburg', image: 'https://i.imgur.com/Sd1AgUOm.jpg', } }); function handleNameChange(e) { updatePerson(draft => { draft.name = e.target.value; }); } function handleTitleChange(e) { updatePerson(draft => { draft.artwork.title = e.target.value; }); } function handleCityChange(e) { updatePerson(draft => { draft.artwork.city = e.target.value; }); } function handleImageChange(e) { updatePerson(draft => { draft.artwork.image = e.target.value; }); } return ( <> <label> Name: <input value={person.name} onChange={handleNameChange} /> </label> <label> Title: <input value={person.artwork.title} onChange={handleTitleChange} /> </label> <label> City: <input value={person.artwork.city} onChange={handleCityChange} /> </label> <label> Image: <input value={person.artwork.image} onChange={handleImageChange} /> </label> <p> <i>{person.artwork.title}</i> {' by '} {person.name} <br /> (located in {person.artwork.city}) </p> <img src={person.artwork.image} alt={person.artwork.title} /> </> ); }
Nota come gli event handler siano molto più concisi. Puoi usare sia useState
che useImmer
nello stesso componente quante volte desideri. Immer è un ottimo modo per mantenere gli update handler concisi, specialmente se c’è nidificazione nel tuo state e copiare gli oggetti porta a codice ripetitivo.
Approfondimento
Per diversi motivi:
- Fare debugging: Se usi
console.log
e non muti lo state, i tuoi log passati non verranno sovrascritti dai cambiamenti allo state più recenti. Così puoi chiaramente vedere come lo state cambia tra le varie renderizzazioni. - Ottimizzazioni: Le comuni strategie di ottimizzazione di React si basano sull’evitare lavoro se le props o lo state precedenti sono gli stessi di quelli successivi. Se
prevObj === obj
, si può essere sicuri che nulla sia cambiato al suo interno. - Nuove funzionalità: Le nuove funzionalità di React che stiamo costruendo si basano sul trattare lo state come un’istantanea. Se stai mutando versioni passate dello state, questo ti potrebbe impedire di usare le nuove funzionalità.
- Modifiche dei requisiti: Alcune funzionalità, come implementare Undo/Redo, mostrare una cronologia dei cambiamenti, o consentire all’utente di resettare un form a dei valori precedenti, sono più semplici da attuare quando nulla viene mutato. Questo perché puoi conservare copie passate dello state in memoria, e riutilizzarle quando è appropriato. Se inizi con un approccio mutabile, funzionalità come queste possono essere difficili da aggiungere in seguito.
- Implementazione facilitata: Poiché React non si basa sulle mutazioni, non ha bisogno di fare nulla di speciale con i tuoi oggetti. Non ha bisogno di controllare le loro proprietà, continuamente wrapparle in dei Proxy, o fare altro lavoro all’inizializzazione come molte soluzioni “reattive”. Questo è anche il motivo per cui React ti consente d’inserire qualsiasi oggetto nello state, indipendentemente dalla dimensione, senza ulteriori problemi di prestazioni o correttezza.
Nella pratica, spesso puoi “farla franca” con le mutazioni dello state in React, ma consigliamo caldamente di non farlo in modo da poter utilizzare le nuove funzionalità di React sviluppate in base a questo approccio. I futuri contributori e forse persino il te del futuro ne saranno grati!
Riepilogo
- Tratta tutto lo state in React come immutabile.
- Quando memorizzi oggetti nello state, mutarli non triggera renderizzazioni e modifica lo state nelle “istantanee” delle renderizzazioni precedenti.
- Invece di mutare un oggetto, crea una nuova versione di esso, e triggera una re-renderizzazione impostandolo nello state.
- Puoi usare la sintassi di spread degli oggetti
{...obj, something: 'newValue'}
per creare copie di oggetti. - La sintassi di spread è superficiale: copia solo il primo livello di profondità.
- Per aggiornare un oggetto nidificato, devi creare copie risalendo fino all’oggetto principale.
- Per ridurre la ripetizione del codice quando copi gli oggetti, usa Immer.
Sfida 1 di 3: Correggi gli aggiornamenti di state sbagliati
Questo form presenta alcuni bug. Clicca il bottone che incrementa il punteggio alcune volte. Nota come non viene incrementato. Poi modifica il nome, e nota come il punteggio improvvisamente “recupera” i tuoi cambiamenti. Infine, modifica il cognome, e nota come il punteggio scompare del tutto.
Il tuo compito è correggere tutti questi bug. Mentre lo fai, cerca di spiegare perché ciascuno di essi si presenta.
import { useState } from 'react'; export default function Scoreboard() { const [player, setPlayer] = useState({ firstName: 'Ranjani', lastName: 'Shettar', score: 10, }); function handlePlusClick() { player.score++; } function handleFirstNameChange(e) { setPlayer({ ...player, firstName: e.target.value, }); } function handleLastNameChange(e) { setPlayer({ lastName: e.target.value }); } return ( <> <label> Score: <b>{player.score}</b> {' '} <button onClick={handlePlusClick}> +1 </button> </label> <label> First name: <input value={player.firstName} onChange={handleFirstNameChange} /> </label> <label> Last name: <input value={player.lastName} onChange={handleLastNameChange} /> </label> </> ); }