Faire remonter l'état
Plusieurs composants ont souvent besoin de refléter les mêmes données dynamiques. Nous conseillons de faire remonter l’état partagé dans leur ancêtre commun le plus proche. Voyons comment ça marche.
Dans cette section, nous allons créer un calculateur de température qui détermine si l’eau bout à une température donnée.
Commençons par un composant appelé BoilingVerdict
. Il accepte une prop celsius
pour la température, et il affiche si elle est suffisante pour faire bouillir l’eau :
function BoilingVerdict(props) {
if (props.celsius >= 100) {
return <p>L'eau bout.</p>; }
return <p>L'eau ne bout pas.</p>;}
Ensuite, nous allons créer un composant appelé Calculator
. Il affiche un <input>
qui permet de saisir une température et de conserver sa valeur dans this.state.temperature
.
Par ailleurs, il affiche le BoilingVerdict
pour la température saisie.
class Calculator extends React.Component {
constructor(props) {
super(props);
this.handleChange = this.handleChange.bind(this);
this.state = {temperature: ''}; }
handleChange(e) {
this.setState({temperature: e.target.value}); }
render() {
const temperature = this.state.temperature; return (
<fieldset>
<legend>Saisissez la température en Celsius :</legend>
<input value={temperature} onChange={this.handleChange} /> <BoilingVerdict celsius={parseFloat(temperature)} /> </fieldset>
);
}
}
Ajouter un deuxième champ
Il nous faut à présent proposer, en plus d’une saisie en Celsius, une saisie en Fahrenheit, les deux devant rester synchronisées.
On peut commencer par extraire un composant TemperatureInput
du code de Calculator
. Ajoutons-y une prop scale
qui pourra être soit "c"
, soit "f"
:
const scaleNames = { c: 'Celsius', f: 'Fahrenheit'};
class TemperatureInput extends React.Component {
constructor(props) {
super(props);
this.handleChange = this.handleChange.bind(this);
this.state = {temperature: ''};
}
handleChange(e) {
this.setState({temperature: e.target.value});
}
render() {
const temperature = this.state.temperature;
const scale = this.props.scale; return (
<fieldset>
<legend>Saisissez la température en {scaleNames[scale]} :</legend> <input value={temperature}
onChange={this.handleChange} />
</fieldset>
);
}
}
Nous pouvons désormais modifier le composant Calculator
pour afficher deux saisies de température :
class Calculator extends React.Component {
render() {
return (
<div>
<TemperatureInput scale="c" /> <TemperatureInput scale="f" /> </div>
);
}
}
Nous avons maintenant deux champs de saisie, mais lorsque vous saisissez la température dans un des deux, l’autre ne se met pas à jour. Nous avons besoin de les garder synchronisés.
Qui plus est, nous ne pouvons pas afficher le BoilingVerdict
depuis Calculator
. Le composant Calculator
n’a pas accès à la température saisie, car elle est cachée dans le TemperatureInput
.
Écrire des fonctions de conversion
D’abord, écrivons deux fonctions pour convertir de Celsius à Fahrenheit et réciproquement :
function toCelsius(fahrenheit) {
return (fahrenheit - 32) * 5 / 9;
}
function toFahrenheit(celsius) {
return (celsius * 9 / 5) + 32;
}
Ces deux fonctions convertissent des nombres. Écrivons une autre fonction qui prend en arguments une chaîne de caractères temperature
et une fonction de conversion, et qui renvoie une chaîne. Nous utiliserons cette nouvelle fonction pour calculer la valeur d’un champ en fonction de l’autre.
Elle renvoie une chaîne vide pour une temperature
incorrecte, et arrondit la valeur de retour à trois décimales :
function tryConvert(temperature, convert) {
const input = parseFloat(temperature);
if (Number.isNaN(input)) {
return '';
}
const output = convert(input);
const rounded = Math.round(output * 1000) / 1000;
return rounded.toString();
}
Par exemple, tryConvert('abc', toCelsius)
renvoie une chaîne vide, et tryConvert('10.22', toFahrenheit)
renvoie '50.396'
.
Faire remonter l’état
Pour l’instant, les deux éléments TemperatureInput
conservent leur propre état local indépendamment l’un de l’autre :
class TemperatureInput extends React.Component {
constructor(props) {
super(props);
this.handleChange = this.handleChange.bind(this);
this.state = {temperature: ''}; }
handleChange(e) {
this.setState({temperature: e.target.value}); }
render() {
const temperature = this.state.temperature; // ...
Cependant, nous voulons que les deux champs soient synchronisés. Lorsqu’on modifie le champ en Celsius, celui en Fahrenheit doit refléter la température convertie, et réciproquement.
Avec React, partager l’état est possible en le déplaçant dans le plus proche ancêtre commun. On appelle ça « faire remonter l’état ». Nous allons supprimer l’état local de TemperatureInput
et le déplacer dans le composant Calculator
.
Si le composant Calculator
est responsable de l’état partagé, il devient la « source de vérité » pour la température des deux champs. Il peut leur fournir des valeurs qui soient cohérentes l’une avec l’autre. Comme les props des deux composants TemperatureInput
viennent du même composant parent Calculator
, les deux champs seront toujours synchronisés.
Voyons comment ça marche étape par étape.
D’abord, remplaçons this.state.temperature
par this.props.temperature
dans le composant TemperatureInput
. Pour le moment, faisons comme si this.props.temperature
existait déjà, même si nous allons devoir la passer depuis Calculator
plus tard :
render() {
// Avant : const temperature = this.state.temperature;
const temperature = this.props.temperature; // ...
On sait que les props sont en lecture seule. Quand la temperature
était dans l’état local, le composant TemperatureInput
pouvait simplement appeler this.setState()
pour la changer. Cependant, maintenant que temperature
vient du parent par une prop, le composant TemperatureInput
n’a pas le contrôle dessus.
Avec React, on gère généralement ça en rendant le composant « contrôlé ». Tout comme un élément DOM <input>
accepte des props value
et onChange
, notre TemperatureInput
peut accepter des props temperature
et onTemperatureChange
fournies par son parent Calculator
.
Maintenant, quand le composant TemperatureInput
veut mettre à jour la température, il appelle this.props.onTemperatureChange
:
handleChange(e) {
// Avant : this.setState({temperature: e.target.value});
this.props.onTemperatureChange(e.target.value); // ...
Remarque
Les noms de props
temperature
etonTemperatureChange
n’ont pas de sens particulier. On aurait pu les appeler n’importe comment, par exemplevalue
etonChange
, qui constituent une convention de nommage répandue.
La prop onTemperatureChange
sera fournie par le composant parent Calculator
, tout comme la prop temperature
. Elle s’occupera du changement en modifiant son propre état local, entraînant un nouvel affichage des deux champs avec leurs nouvelles valeurs. Nous allons nous pencher très bientôt sur l’implémentation du nouveau composant Calculator
.
Avant de modifier le composant Calculator
, récapitulons les modifications apportées au composant TemperatureInput
. Nous en avons retiré l’état local, et nous lisons désormais this.props.temperature
au lieu de this.state.temperature
. Plutôt que d’appeler this.setState()
quand on veut faire un changement, on appelle désormais this.props.onTemperatureChange()
, qui est fournie par le Calculator
:
class TemperatureInput extends React.Component {
constructor(props) {
super(props);
this.handleChange = this.handleChange.bind(this);
}
handleChange(e) {
this.props.onTemperatureChange(e.target.value); }
render() {
const temperature = this.props.temperature; const scale = this.props.scale;
return (
<fieldset>
<legend>Saisissez la température en {scaleNames[scale]} :</legend>
<input value={temperature}
onChange={this.handleChange} />
</fieldset>
);
}
}
Intéressons-nous maintenant au composant Calculator
.
Nous allons stocker la valeur courante de temperature
et de scale
dans son état local. C’est l’état que nous avons « remonté » depuis les champs, et il servira de « source de vérité » pour eux deux. C’est la représentation minimale des données dont nous avons besoin afin d’afficher les deux champs.
Par exemple, si on saisit 37 dans le champ en Celsius, l’état local du composant Calculator
sera :
{
temperature: '37',
scale: 'c'
}
Si plus tard on change le champ Fahrenheit à 212, l’état local du composant Calculator
sera :
{
temperature: '212',
scale: 'f'
}
On pourrait avoir stocké les valeurs des deux champs, mais en fait ce n’est pas nécessaire. Stocker uniquement la valeur la plus récente et son unité s’avère suffisant. On peut déduire la valeur de l’autre champ rien qu’à partir des valeurs de temperature
et de scale
stockées.
Les champs restent synchronisés car leurs valeurs sont calculées depuis le même état :
class Calculator extends React.Component {
constructor(props) {
super(props);
this.handleCelsiusChange = this.handleCelsiusChange.bind(this);
this.handleFahrenheitChange = this.handleFahrenheitChange.bind(this);
this.state = {temperature: '', scale: 'c'}; }
handleCelsiusChange(temperature) {
this.setState({scale: 'c', temperature}); }
handleFahrenheitChange(temperature) {
this.setState({scale: 'f', temperature}); }
render() {
const scale = this.state.scale; const temperature = this.state.temperature; const celsius = scale === 'f' ? tryConvert(temperature, toCelsius) : temperature; const fahrenheit = scale === 'c' ? tryConvert(temperature, toFahrenheit) : temperature;
return (
<div>
<TemperatureInput
scale="c"
temperature={celsius} onTemperatureChange={this.handleCelsiusChange} /> <TemperatureInput
scale="f"
temperature={fahrenheit} onTemperatureChange={this.handleFahrenheitChange} /> <BoilingVerdict
celsius={parseFloat(celsius)} /> </div>
);
}
}
Désormais, quel que soit le champ que vous modifiez, this.state.temperature
et this.state.scale
seront mis à jour au sein du composant Calculator
. L’un des deux champ recevra la valeur telle quelle, et l’autre valeur de champ sera toujours recalculée à partir de la valeur modifiée.
Récapitulons ce qui se passe quand on change la valeur d’un champ :
- React appelle la fonction spécifiée dans l’attribut
onChange
de l’élément DOM<input>
. Dans notre cas, c’est la méthodehandleChange
du composantTemperatureInput
. - La méthode
handleChange
du composantTemperatureInput
appellethis.props.onTemperatureChange()
avec la nouvelle valeur. Ses props, notammentonTemperatureChange
, ont été fournies par son composant parent,Calculator
. - Au dernier affichage en date, le composant
Calculator
avait passé la méthodehandleCelsiusChange
deCalculator
comme proponTemperatureChange
duTemperatureInput
en Celsius, et la méthodehandleFahrenheitChange
deCalculator
comme proponTemperatureChange
duTemperatureInput
en Fahrenheit. L’une ou l’autre de ces méthodes deCalculator
sera ainsi appelée en fonction du champ modifié. - Dans ces méthodes, le composant
Calculator
demande à React de le rafraîchir en appelantthis.setState()
avec la nouvelle valeur du champ et l’unité du champ modifié. - React appelle la méthode
render
du composantCalculator
afin de savoir à quoi devrait ressembler son UI. Les valeurs des deux champs sont recalculées en fonction de la température actuelle et de l’unité active. La conversion de température est faite ici. - React appelle les méthodes
render
des deux composantsTemperatureInput
avec leurs nouvelles props, spécifiées par leCalculator
. React sait alors à quoi devraient ressembler leurs UI. - React appelle la méthode
render
du composantBoilingVerdict
, en lui passant la température en Celsius dans les props. - React DOM met à jour le DOM avec le verdict d’ébullition, et retranscrit les valeurs de champs souhaitées. Le champ que nous venons de modifier reçoit sa valeur actuelle, et l’autre champ est mis à jour avec la température convertie.
Chaque mise à jour suit ces mêmes étapes, ainsi les champs restent synchronisés.
Ce qu’il faut retenir
Il ne doit y avoir qu’une seule « source de vérité » pour toute donnée qui change dans une application React. En général, l’état est d’abord ajouté au composant qui en a besoin pour s’afficher. Ensuite, si d’autres composants en ont également besoin, vous pouvez faire remonter l’état dans l’ancêtre commun le plus proche. Au lieu d’essayer de synchroniser les états de différents composants, vous devriez vous baser sur des données qui se propagent du haut vers le bas.
Faire remonter l’état implique d’écrire plus de code générique (boilerplate code, NdT) qu’avec une liaison de données bidirectionnelle, mais le jeu en vaut la chandelle, car ça demande moins de travail pour trouver et isoler les bugs. Puisque tout état « vit » dans un composant et que seul ce composant peut le changer, la surface d’impact des bugs est grandement réduite. Qui plus est, vous pouvez implémenter n’importe quelle logique personnalisée pour rejeter ou transformer les saisies des utilisateurs.
Si quelque chose peut être dérivé des props ou de l’état, cette chose ne devrait probablement pas figurer dans l’état. Par exemple, plutôt que de stocker à la fois celsiusValue
et fahrenheitValue
, on stocke uniquement la dernière temperature
modifiée et son unité scale
. La valeur de l’autre champ peut toujours être calculée dans la méthode render()
à partir de la valeur de ces données. Ça nous permet de vider ou d’arrondir la valeur de l’autre champ sans perdre la valeur saisie par l’utilisateur.
Quand vous voyez quelque chose qui ne va pas dans l’UI, vous pouvez utiliser les outils de développement React pour examiner les props et remonter dans l’arborescence des composants jusqu’à trouver le composant responsable de la mise à jour de l’état. Ça vous permet de remonter à la source des bugs :