Skip to content

Latest commit

 

History

History
2184 lines (1785 loc) · 57.5 KB

File metadata and controls

2184 lines (1785 loc) · 57.5 KB
title Préserver et réinitialiser l’état

L'état est isolé entre les composants. React garde en mémoire quel état appartient à quel composant en fonction de leur place dans l'arbre de l'interface utilisateur (l'UI). Vous pouvez contrôler quand préserver l'état et quand le réinitialiser entre les différents rendus.

  • Quand React choisit de préserver ou de réinitialiser l'état
  • Comment forcer React à réinitialiser l'état d'un composant
  • Comment les clés et les types déterminent si l'état est préservé ou non

L'état est lié à une position dans l'arbre {/state-is-tied-to-a-position-in-the-tree/}

React construit un arbre de rendu pour représenter la structure des composants de votre UI.

Lorsque vous donnez un état à un composant, vous pouvez penser que l'état « vit » à l'intérieur du composant. En réalité, l'état est conservé à l'intérieur de React. React associe chaque élément d'état qu'il conserve au composant correspondant en fonction de la place que celui-ci occupe dans l'arbre de rendu.

Ci-dessous, il n'y a qu'une seule balise <Counter />, pourtant elle est affichée à deux positions différentes :

import { useState } from 'react';

export default function App() {
  const counter = <Counter />;
  return (
    <div>
      {counter}
      {counter}
    </div>
  );
}

function Counter() {
  const [score, setScore] = useState(0);
  const [hover, setHover] = useState(false);

  let className = 'counter';
  if (hover) {
    className += ' hover';
  }

  return (
    <div
      className={className}
      onPointerEnter={() => setHover(true)}
      onPointerLeave={() => setHover(false)}
    >
      <h1>{score}</h1>
      <button onClick={() => setScore(score + 1)}>
        Incrémenter
      </button>
    </div>
  );
}
label {
  display: block;
  clear: both;
}

.counter {
  width: 150px;
  text-align: center;
  border: 1px solid gray;
  border-radius: 4px;
  padding: 20px;
  margin: 0 20px 20px 0;
  float: left;
}

.hover {
  background: #ffffd8;
}

Voici comment les visualiser sous forme d'arbre :

L'arbre de React

Il s'agit de deux compteurs distincts car chacun d'eux a sa propre position dans l'arbre. Généralement, vous n'avez pas besoin de penser à ces positions pour utiliser React, mais il peut être utile de comprendre comment ça fonctionne.

Dans React, chaque composant à l'écran a son propre état complétement isolé. Par exemple, si vous affichez deux composants Counter l'un à côté de l'autre, chacun d'eux aura ses propres variables d'état indépendantes de score et d'hover.

Cliquez sur chaque compteur et constatez qu'ils ne s'affectent pas l'un l'autre :

import { useState } from 'react';

export default function App() {
  return (
    <div>
      <Counter />
      <Counter />
    </div>
  );
}

function Counter() {
  const [score, setScore] = useState(0);
  const [hover, setHover] = useState(false);

  let className = 'counter';
  if (hover) {
    className += ' hover';
  }

  return (
    <div
      className={className}
      onPointerEnter={() => setHover(true)}
      onPointerLeave={() => setHover(false)}
    >
      <h1>{score}</h1>
      <button onClick={() => setScore(score + 1)}>
        Incrémenter
      </button>
    </div>
  );
}
.counter {
  width: 150px;
  text-align: center;
  border: 1px solid gray;
  border-radius: 4px;
  padding: 20px;
  margin: 0 20px 20px 0;
  float: left;
}

.hover {
  background: #ffffd8;
}

Comme vous pouvez le voir, quand un compteur est mis à jour, seul l'état de ce composant est mis à jour :

Mise à jour de l’état

React conservera l'état tant que vous afficherez le même composant à la même position dans l'arbre. Pour vous en rendre compte, incrémentez les deux compteurs, puis supprimez le deuxième composant en décochant « Afficher le deuxième compteur », et enfin remettez-le en cochant à nouveau la case :

import { useState } from 'react';

export default function App() {
  const [showB, setShowB] = useState(true);
  return (
    <div>
      <Counter />
      {showB && <Counter />}
      <label>
        <input
          type="checkbox"
          checked={showB}
          onChange={e => {
            setShowB(e.target.checked)
          }}
        />
        Afficher le deuxième compteur
      </label>
    </div>
  );
}

function Counter() {
  const [score, setScore] = useState(0);
  const [hover, setHover] = useState(false);

  let className = 'counter';
  if (hover) {
    className += ' hover';
  }

  return (
    <div
      className={className}
      onPointerEnter={() => setHover(true)}
      onPointerLeave={() => setHover(false)}
    >
      <h1>{score}</h1>
      <button onClick={() => setScore(score + 1)}>
        Incrémenter
      </button>
    </div>
  );
}
label {
  display: block;
  clear: both;
}

.counter {
  width: 150px;
  text-align: center;
  border: 1px solid gray;
  border-radius: 4px;
  padding: 20px;
  margin: 0 20px 20px 0;
  float: left;
}

.hover {
  background: #ffffd8;
}

Remarquez qu'au moment où vous cessez d'afficher le deuxième compteur, son état disparaît complètement. Lorsque React supprime un composant, il supprime également son état.

Suppression d’un composant

Lorsque vous cochez « Afficher le deuxième compteur », un deuxième Counter avec son état associé sont initialisés de zéro (score = 0), puis ajoutés au DOM.

Ajout d’un composant

React préserve l'état d'un composant tant qu'il est affiché à sa position dans l'arbre de l'UI. S'il est supprimé, ou si un composant différent est affiché à la même position, alors React se débarrasse de son état.

Le même composant à la même position préserve son état {/same-component-at-the-same-position-preserves-state/}

Dans cet exemple, il y a deux balises <Counter /> différentes :

import { useState } from 'react';

export default function App() {
  const [isFancy, setIsFancy] = useState(false);
  return (
    <div>
      {isFancy ? (
        <Counter isFancy={true} />
      ) : (
        <Counter isFancy={false} />
      )}
      <label>
        <input
          type="checkbox"
          checked={isFancy}
          onChange={e => {
            setIsFancy(e.target.checked)
          }}
        />
        Utiliser un style fantaisiste
      </label>
    </div>
  );
}

function Counter({ isFancy }) {
  const [score, setScore] = useState(0);
  const [hover, setHover] = useState(false);

  let className = 'counter';
  if (hover) {
    className += ' hover';
  }
  if (isFancy) {
    className += ' fancy';
  }

  return (
    <div
      className={className}
      onPointerEnter={() => setHover(true)}
      onPointerLeave={() => setHover(false)}
    >
      <h1>{score}</h1>
      <button onClick={() => setScore(score + 1)}>
        Incrémenter
      </button>
    </div>
  );
}
label {
  display: block;
  clear: both;
}

.counter {
  width: 150px;
  text-align: center;
  border: 1px solid gray;
  border-radius: 4px;
  padding: 20px;
  margin: 0 20px 20px 0;
  float: left;
}

.fancy {
  border: 5px solid gold;
  color: #ff6767;
}

.hover {
  background: #ffffd8;
}

Quand vous cochez ou décochez la case, l'état du compteur n'est pas réinitialisé. Que isFancy soit à true ou à false, vous avez toujours un <Counter /> comme premier enfant du div renvoyé par le composant racine App :

Mettre à jour l’état de App ne remet pas à jour le Counter parce que ce dernier reste à la même position

C'est le même composant à la même position, donc du point de vue de React, il s'agit du même compteur.

Souvenez-vous que c'est la position dans l'arbre de l'UI — et non dans le JSX — qui importe à React ! Ce composant a deux clauses return avec des balises JSX différentes de <Counter /> à l'intérieur et l'extérieur du if :

import { useState } from 'react';

export default function App() {
  const [isFancy, setIsFancy] = useState(false);
  if (isFancy) {
    return (
      <div>
        <Counter isFancy={true} />
        <label>
          <input
            type="checkbox"
            checked={isFancy}
            onChange={e => {
              setIsFancy(e.target.checked)
            }}
          />
          Utiliser un style fantaisiste
        </label>
      </div>
    );
  }
  return (
    <div>
      <Counter isFancy={false} />
      <label>
        <input
          type="checkbox"
          checked={isFancy}
          onChange={e => {
            setIsFancy(e.target.checked)
          }}
        />
        Utiliser un style fantaisiste
      </label>
    </div>
  );
}

function Counter({ isFancy }) {
  const [score, setScore] = useState(0);
  const [hover, setHover] = useState(false);

  let className = 'counter';
  if (hover) {
    className += ' hover';
  }
  if (isFancy) {
    className += ' fancy';
  }

  return (
    <div
      className={className}
      onPointerEnter={() => setHover(true)}
      onPointerLeave={() => setHover(false)}
    >
      <h1>{score}</h1>
      <button onClick={() => setScore(score + 1)}>
        Incrémenter
      </button>
    </div>
  );
}
label {
  display: block;
  clear: both;
}

.counter {
  width: 150px;
  text-align: center;
  border: 1px solid gray;
  border-radius: 4px;
  padding: 20px;
  margin: 0 20px 20px 0;
  float: left;
}

.fancy {
  border: 5px solid gold;
  color: #ff6767;
}

.hover {
  background: #ffffd8;
}

Vous pourriez supposer que l'état est réinitialisé quand vous cochez la case, mais ce n'est pas le cas ! C'est parce que les deux balises <Counter /> sont affichées à la même position. React ne sait pas où vous placez les conditions dans votre fonction. Tout ce qu'il « voit » c'est l'arbre qui est renvoyé.

Dans les deux cas, le composant App renvoie un <div> avec un <Counter /> comme premier enfant. Pour React, ces deux compteurs ont la même « adresse » : le premier enfant du premier enfant de la racine. C'est ainsi que React les associe d'un rendu à l'autre, peu importe la façon dont vous structurez votre logique.

Des composants différents à la même position réinitialisent l'état {/different-components-at-the-same-position-reset-state/}

Dans cet exemple, cliquer sur la case remplacera <Counter> par un <p> :

import { useState } from 'react';

export default function App() {
  const [isPaused, setIsPaused] = useState(false);
  return (
    <div>
      {isPaused ? (
        <p>À bientôt !</p>
      ) : (
        <Counter />
      )}
      <label>
        <input
          type="checkbox"
          checked={isPaused}
          onChange={e => {
            setIsPaused(e.target.checked)
          }}
        />
        Faire une pause
      </label>
    </div>
  );
}

function Counter() {
  const [score, setScore] = useState(0);
  const [hover, setHover] = useState(false);

  let className = 'counter';
  if (hover) {
    className += ' hover';
  }

  return (
    <div
      className={className}
      onPointerEnter={() => setHover(true)}
      onPointerLeave={() => setHover(false)}
    >
      <h1>{score}</h1>
      <button onClick={() => setScore(score + 1)}>
        Incrémenter
      </button>
    </div>
  );
}
label {
  display: block;
  clear: both;
}

.counter {
  width: 150px;
  text-align: center;
  border: 1px solid gray;
  border-radius: 4px;
  padding: 20px;
  margin: 0 20px 20px 0;
  float: left;
}

.hover {
  background: #ffffd8;
}

Vous basculez ici entre deux types de composants différents à la même position. À l'origine, le premier enfant du <div> contenait un Counter. Ensuite, comme vous l'avez échangé avec un p, React a supprimé le Counter de l'UI et détruit son état.

Quand Counter est changé en p, le Counter est supprimé et le p est ajouté

En revenant en arrière, le p est supprimé et le Counter est ajouté

Ainsi, quand vous faites le rendu d'un composant différent à la même position, l'état de tout son sous-arbre est réinitialisé. Pour comprendre comment ça fonctionne, incrémentez le compteur et cochez la case :

import { useState } from 'react';

export default function App() {
  const [isFancy, setIsFancy] = useState(false);
  return (
    <div>
      {isFancy ? (
        <div>
          <Counter isFancy={true} />
        </div>
      ) : (
        <section>
          <Counter isFancy={false} />
        </section>
      )}
      <label>
        <input
          type="checkbox"
          checked={isFancy}
          onChange={e => {
            setIsFancy(e.target.checked)
          }}
        />
        Utiliser un style fantaisiste
      </label>
    </div>
  );
}

function Counter({ isFancy }) {
  const [score, setScore] = useState(0);
  const [hover, setHover] = useState(false);

  let className = 'counter';
  if (hover) {
    className += ' hover';
  }
  if (isFancy) {
    className += ' fancy';
  }

  return (
    <div
      className={className}
      onPointerEnter={() => setHover(true)}
      onPointerLeave={() => setHover(false)}
    >
      <h1>{score}</h1>
      <button onClick={() => setScore(score + 1)}>
        Incrémenter
      </button>
    </div>
  );
}
label {
  display: block;
  clear: both;
}

.counter {
  width: 150px;
  text-align: center;
  border: 1px solid gray;
  border-radius: 4px;
  padding: 20px;
  margin: 0 20px 20px 0;
  float: left;
}

.fancy {
  border: 5px solid gold;
  color: #ff6767;
}

.hover {
  background: #ffffd8;
}

L'état du compteur se réinitialise quand vous cliquez sur la case. Bien que vous affichiez un Counter, le premier enfant du div passe d'un div à une section. Lorsque l'enfant div a été retiré du DOM, tout l'arbre en dessous de lui (ce qui inclut le Counter et son état) a également été détruit.

Quand la section change pour un div, la section est supprimée est le nouveau div est ajouté

En revenant en arrière, le div est supprimé et la nouvelle section est ajoutée

De manière générale, si vous voulez préserver l'état entre les rendus, la structure de votre arbre doit « correspondre » d'un rendu à l'autre. Si la structure est différente, l'état sera détruit car React détruit l'état quand il enlève un composant de l'arbre.

Voici pourquoi il ne faut pas imbriquer les définitions des fonctions des composants.

Ici, la fonction du composant MyTextField est définie à l'intérieur de MyComponent :

import { useState } from 'react';

export default function MyComponent() {
  const [counter, setCounter] = useState(0);

  function MyTextField() {
    const [text, setText] = useState('');

    return (
      <input
        value={text}
        onChange={e => setText(e.target.value)}
      />
    );
  }

  return (
    <>
      <MyTextField />
      <button onClick={() => {
        setCounter(counter + 1)
      }}>Cliqué {counter} fois</button>
    </>
  );
}

Chaque fois que vous appuyez sur le bouton, l'état du champ de saisie disparaît ! C'est parce qu'une fonction MyTextField différente est créée à chaque rendu de MyComponent. Puisque vous affichez un composant différent à la même position, React réinitialise tout l'état en dessous. Ça cause des bugs et des problèmes de performances. Pour éviter ce problème, déclarez toujours les fonctions de composants au niveau racine, et n'imbriquez pas leurs définitions.

Réinitialiser l'état à la même position {/resetting-state-at-the-same-position/}

Par défaut, React préserve l'état d'un composant tant que celui-ci conserve sa position. Généralement, c'est exactement ce que vous voulez, c'est donc logique qu'il s'agisse du comportement par défaut. Cependant, il peut arriver que vous vouliez réinitialiser l'état d'un composant. Regardez cette appli qui permet à deux joueurs de surveiller leur score pendant leur tour :

import { useState } from 'react';

export default function Scoreboard() {
  const [isPlayerA, setIsPlayerA] = useState(true);
  return (
    <div>
      {isPlayerA ? (
        <Counter person="Clara" />
      ) : (
        <Counter person="Sarah" />
      )}
      <button onClick={() => {
        setIsPlayerA(!isPlayerA);
      }}>
        Joueur suivant !
      </button>
    </div>
  );
}

function Counter({ person }) {
  const [score, setScore] = useState(0);
  const [hover, setHover] = useState(false);

  let className = 'counter';
  if (hover) {
    className += ' hover';
  }

  return (
    <div
      className={className}
      onPointerEnter={() => setHover(true)}
      onPointerLeave={() => setHover(false)}
    >
      <h1>Score de {person} : {score}</h1>
      <button onClick={() => setScore(score + 1)}>
        Incrémenter
      </button>
    </div>
  );
}
h1 {
  font-size: 18px;
}

.counter {
  width: 150px;
  text-align: center;
  border: 1px solid gray;
  border-radius: 4px;
  padding: 20px;
  margin: 0 20px 20px 0;
}

.hover {
  background: #ffffd8;
}

Pour le moment, le score est conservé quand vous changez de joueur. Les deux Counter apparaissent à la même position, donc React les voit comme le même Counter dont la prop person a changé.

Conceptuellement, dans cette appli, ils doivent être considérés comme deux compteurs distincts. Ils apparaissent certes à la même place dans l'UI, mais l'un est pour Clara, l'autre pour Sarah.

Il y a deux façons de réinitialiser l'état lorsqu'on passe de l'un à l'autre :

  1. Afficher les composants à deux positions différentes.
  2. Donner explicitement à chaque composant une identité avec key.

Option 1 : changer la position du composant {/option-1-rendering-a-component-in-different-positions/}

Si vous souhaitez rendre ces deux Counter indépendants, vous pouvez choisir de les afficher à deux positions différentes :

import { useState } from 'react';

export default function Scoreboard() {
  const [isPlayerA, setIsPlayerA] = useState(true);
  return (
    <div>
      {isPlayerA &&
        <Counter person="Clara" />
      }
      {!isPlayerA &&
        <Counter person="Sarah" />
      }
      <button onClick={() => {
        setIsPlayerA(!isPlayerA);
      }}>
        Joueur suivant !
      </button>
    </div>
  );
}

function Counter({ person }) {
  const [score, setScore] = useState(0);
  const [hover, setHover] = useState(false);

  let className = 'counter';
  if (hover) {
    className += ' hover';
  }

  return (
    <div
      className={className}
      onPointerEnter={() => setHover(true)}
      onPointerLeave={() => setHover(false)}
    >
      <h1>Score de {person} : {score}</h1>
      <button onClick={() => setScore(score + 1)}>
        Incrémenter
      </button>
    </div>
  );
}
h1 {
  font-size: 18px;
}

.counter {
  width: 150px;
  text-align: center;
  border: 1px solid gray;
  border-radius: 4px;
  padding: 20px;
  margin: 0 20px 20px 0;
}

.hover {
  background: #ffffd8;
}
  • Initialement, isPlayerA vaut true. Ainsi la première position contient l'état de Counter, tandis que la seconde position est vide.
  • Quand vous cliquez sur le bouton « Joueur suivant », la première position se vide et la seconde contient désormais un Counter.

État initial

Appui sur « Joueur suivant »

Nouvel appui sur « Joueur suivant »

Chaque état de Counter est supprimé dès que ce dernier est retiré du DOM. C'est pour ça qu'il est réinitialisé à chaque fois que vous appuyez sur le bouton.

Cette solution est pratique quand vous n'avez qu'un petit nombre de composants indépendants à afficher à la même position dans l'arbre. Dans cet exemple, vous n'en avez que deux, ce n'est donc pas compliqué de faire leurs rendus séparément dans le JSX.

Option 2 : réinitialiser l'état avec une clé {/option-2-resetting-state-with-a-key/}

Il existe une méthode plus générique pour réinitialiser l'état d'un composant.

Vous avez peut-être déjà vu les key lors de l'affichage des listes. Ces clés ne sont pas réservées aux listes ! Vous pouvez les utiliser pour aider React à faire la distinction entre n'importe quels composants. Par défaut, React utilise l'ordre dans un parent (« premier compteur », « deuxième compteur ») pour différencier les composants. Les clés vous permettent de dire à React qu'il ne s'agit pas simplement d'un premier compteur ou d'un deuxième compteur, mais plutôt un compteur spécifique — par exemple le compteur de Clara. De cette façon, React reconnaîtra le compteur de Clara où qu'il apparaisse dans l'arbre.

Dans cet exemple, les deux <Counter /> ne partagent pas leur état, bien qu'ils apparaissent à la même position dans le JSX :

import { useState } from 'react';

export default function Scoreboard() {
  const [isPlayerA, setIsPlayerA] = useState(true);
  return (
    <div>
      {isPlayerA ? (
        <Counter key="Clara" person="Clara" />
      ) : (
        <Counter key="Sarah" person="Sarah" />
      )}
      <button onClick={() => {
        setIsPlayerA(!isPlayerA);
      }}>
        Joueur suivant !
      </button>
    </div>
  );
}

function Counter({ person }) {
  const [score, setScore] = useState(0);
  const [hover, setHover] = useState(false);

  let className = 'counter';
  if (hover) {
    className += ' hover';
  }

  return (
    <div
      className={className}
      onPointerEnter={() => setHover(true)}
      onPointerLeave={() => setHover(false)}
    >
      <h1>Score de {person} : {score}</h1>
      <button onClick={() => setScore(score + 1)}>
        Incrémenter
      </button>
    </div>
  );
}
h1 {
  font-size: 18px;
}

.counter {
  width: 150px;
  text-align: center;
  border: 1px solid gray;
  border-radius: 4px;
  padding: 20px;
  margin: 0 20px 20px 0;
}

.hover {
  background: #ffffd8;
}

Remplacer Clara par Sarah ne préserve pas l'état. C'est parce que vous leur avez donné des key différentes :

{isPlayerA ? (
  <Counter key="Clara" person="Clara" />
) : (
  <Counter key="Sarah" person="Sarah" />
)}

Le fait de spécifier une key indique à React de l'utiliser également comme élément de position, plutôt que son ordre au sein du parent. Ainsi, même si vous faites le rendu à la même position dans le JSX, React les voit comme deux compteurs distincts qui ne partageront jamais leur état. À chaque fois qu'un compteur apparaît à l'écran, son état est créé. À chaque fois qu'il est supprimé, son état est supprimé. Passer de l'un à l'autre réinitialise leur état, encore et encore.

Retenez que les clés ne sont pas uniques au niveau global. Elles spécifient uniquement la position au sein du parent.

Réinitialiser un formulaire avec une clé {/resetting-a-form-with-a-key/}

Réinitialiser un état avec une clé s'avère particulièrement utile quand on manipule des formulaires.

Dans cette appli de discussions, le composant <Chat> contient l'état du champ de saisie :

import { useState } from 'react';
import Chat from './Chat.js';
import ContactList from './ContactList.js';

export default function Messenger() {
  const [to, setTo] = useState(contacts[0]);
  return (
    <div>
      <ContactList
        contacts={contacts}
        selectedContact={to}
        onSelect={contact => setTo(contact)}
      />
      <Chat contact={to} />
    </div>
  )
}

const contacts = [
  { id: 0, name: 'Clara', email: 'clara@mail.com' },
  { id: 1, name: 'Alice', email: 'alice@mail.com' },
  { id: 2, name: 'Bob', email: 'bob@mail.com' }
];
export default function ContactList({
  selectedContact,
  contacts,
  onSelect
}) {
  return (
    <section className="contact-list">
      <ul>
        {contacts.map(contact =>
          <li key={contact.id}>
            <button onClick={() => {
              onSelect(contact);
            }}>
              {contact.name}
            </button>
          </li>
        )}
      </ul>
    </section>
  );
}
import { useState } from 'react';

export default function Chat({ contact }) {
  const [text, setText] = useState('');
  return (
    <section className="chat">
      <textarea
        value={text}
        placeholder={'Discuter avec ' + contact.name}
        onChange={e => setText(e.target.value)}
      />
      <br />
      <button>Envoyer à {contact.email}</button>
    </section>
  );
}
.chat, .contact-list {
  float: left;
  margin-bottom: 20px;
}
ul, li {
  list-style: none;
  margin: 0;
  padding: 0;
}
li button {
  width: 150px;
  padding: 10px;
  margin-right: 10px;
}
textarea {
  height: 150px;
}

Essayez de saisir quelque chose dans le champ, puis appuyez sur « Alice » ou « Bob » pour choisir un destinataire différent. Vous noterez que le champ de saisie est conservé parce que le <Chat> est affiché à la même position dans l'arbre.

Dans beaucoup d'applis, c'est le comportement désiré, mais pas dans cette appli de discussion ! Vous ne souhaitez pas qu'un utilisateur envoie un message qu'il a déjà tapé à la mauvaise personne à la suite d'un clic malencontreux. Pour corriger ça, ajoutez une key :

<Chat key={to.id} contact={to} />

Ça garantit que lorsque vous sélectionnez un destinataire différent, le composant Chat sera recréé de zéro, ce qui inclut tout l'état dans l'arbre en dessous. React recréera également tous les éléments DOM plutôt que de les réutiliser.

Désormais, changer de destinataire vide le champ de saisie :

import { useState } from 'react';
import Chat from './Chat.js';
import ContactList from './ContactList.js';

export default function Messenger() {
  const [to, setTo] = useState(contacts[0]);
  return (
    <div>
      <ContactList
        contacts={contacts}
        selectedContact={to}
        onSelect={contact => setTo(contact)}
      />
      <Chat key={to.id} contact={to} />
    </div>
  )
}

const contacts = [
  { id: 0, name: 'Clara', email: 'clara@mail.com' },
  { id: 1, name: 'Alice', email: 'alice@mail.com' },
  { id: 2, name: 'Bob', email: 'bob@mail.com' }
];
export default function ContactList({
  selectedContact,
  contacts,
  onSelect
}) {
  return (
    <section className="contact-list">
      <ul>
        {contacts.map(contact =>
          <li key={contact.id}>
            <button onClick={() => {
              onSelect(contact);
            }}>
              {contact.name}
            </button>
          </li>
        )}
      </ul>
    </section>
  );
}
import { useState } from 'react';

export default function Chat({ contact }) {
  const [text, setText] = useState('');
  return (
    <section className="chat">
      <textarea
        value={text}
        placeholder={'Discuter avec ' + contact.name}
        onChange={e => setText(e.target.value)}
      />
      <br />
      <button>Envoyer à {contact.email}</button>
    </section>
  );
}
.chat, .contact-list {
  float: left;
  margin-bottom: 20px;
}
ul, li {
  list-style: none;
  margin: 0;
  padding: 0;
}
li button {
  width: 150px;
  padding: 10px;
  margin-right: 10px;
}
textarea {
  height: 150px;
}

Préserver l'état des composants supprimés {/preserving-state-for-removed-components/}

Dans une véritable appli de discussion, vous souhaiterez probablement récupérer l'état de la saisie lorsque l'utilisateur resélectionne le destinataire précédent. Il existe plusieurs manières de garder « vivant » l'état d'un composant qui n'est plus visible :

  • Vous pouvez afficher toutes les discussions plutôt que seulement celle qui est active, mais en masquant les autres avec du CSS. Les discussions ne seraient pas supprimés de l'arbre, de sorte que leur état local serait préservé. Cette solution fonctionne très bien pour des UI simples. Cependant, ça peut devenir très lent si les arbres cachés sont grands et contiennent de nombreux nœuds DOM.
  • Vous pouvez faire remonter l'état et conserver dans le composant parent le message en attente pour chaque destinataire. De cette façon, le fait que les composants enfants soient supprimés importe peu, car c'est en réalité le parent qui conserve les informations importantes. C'est la solution la plus courante.
  • Vous pouvez aussi utiliser une source différente en plus de l'état React. Par exemple, vous souhaitez sans doute qu'un brouillon du message persiste même si l'utilisateur ferme accidentellement la page. Pour implémenter ça, vous pouvez faire en sorte que le composant Chat intialise son état en lisant le localStorage et y sauve également les brouillons.

Quelle que soit votre stratégie, une discussion avec Alice est conceptuellement différente d'une autre avec Bob, il est donc naturel de donner une key à l'arbre <Chat> en fonction du destinataire actuel.

  • React conserve l'état tant que le même composant est affiché à la même position.
  • L'état n'est pas conservé dans les balises JSX. Il est associé à la position dans l'arbre où vous placez ce JSX.
  • Vous pouvez forcer un sous-arbre à réinitialiser son état en lui donnant une clé différente.
  • N'imbriquez pas les définitions de composants ou vous allez accidentellement réinitialiser leur état.

Corriger une saisie qui disparaît {/fix-disappearing-input-text/}

Cet exemple affiche un message quand vous appuyez sur le bouton. Cependant, appuyer sur ce bouton vide aussi le champ de saisie par accident. Pourquoi ? Corrigez ça pour que le champ de saisie ne se vide pas quand on appuie sur le bouton.

import { useState } from 'react';

export default function App() {
  const [showHint, setShowHint] = useState(false);
  if (showHint) {
    return (
      <div>
        <p><i>Indice : votre ville préférée ?</i></p>
        <Form />
        <button onClick={() => {
          setShowHint(false);
        }}>Cacher l'indice</button>
      </div>
    );
  }
  return (
    <div>
      <Form />
      <button onClick={() => {
        setShowHint(true);
      }}>Afficher l'indice</button>
    </div>
  );
}

function Form() {
  const [text, setText] = useState('');
  return (
    <textarea
      value={text}
      onChange={e => setText(e.target.value)}
    />
  );
}
textarea { display: block; margin: 10px 0; }

Le problème vient de ce que le Form est affiché à des positions différentes. Dans la branche du if, c'est le second enfant du <div>, mais c'est le premier enfant dans la branche else. Du coup, le type du composant à chaque position change. Dans un cas, la première position reçoit un p puis un Form, alors que dans l'autre cas, elle reçoit un Form puis un button. React réinitialise l'état à chaque fois que le type du composant change.

La solution la plus simple consiste à réunir les branches de façon à ce que Form soit toujours affiché à la même position :

import { useState } from 'react';

export default function App() {
  const [showHint, setShowHint] = useState(false);
  return (
    <div>
      {showHint &&
        <p><i>Indice : votre ville favorite ?</i></p>
      }
      <Form />
      {showHint ? (
        <button onClick={() => {
          setShowHint(false);
        }}>Cacher l'indice</button>
      ) : (
        <button onClick={() => {
          setShowHint(true);
        }}>Afficher l'indice</button>
      )}
    </div>
  );
}

function Form() {
  const [text, setText] = useState('');
  return (
    <textarea
      value={text}
      onChange={e => setText(e.target.value)}
    />
  );
}
textarea { display: block; margin: 10px 0; }

Techniquement, vous pourriez aussi ajouter un null avant le <Form /> dans la branche else pour que ça corresponde à la structure de la branche if :

import { useState } from 'react';

export default function App() {
  const [showHint, setShowHint] = useState(false);
  if (showHint) {
    return (
      <div>
        <p><i>Indice : votre ville favorite ?</i></p>
        <Form />
        <button onClick={() => {
          setShowHint(false);
        }}>Cacher l'indice</button>
      </div>
    );
  }
  return (
    <div>
      {null}
      <Form />
      <button onClick={() => {
        setShowHint(true);
      }}>Afficher l'indice</button>
    </div>
  );
}

function Form() {
  const [text, setText] = useState('');
  return (
    <textarea
      value={text}
      onChange={e => setText(e.target.value)}
    />
  );
}
textarea { display: block; margin: 10px 0; }

De cette façon, Form est toujours le second enfant, il conserve donc sa position et garde son état. Cette approche est toutefois moins intuitive et introduit le risque que quelqu'un vienne supprimer ce null.

Échanger deux champs du formulaire {/swap-two-form-fields/}

Ce formulaire vous permet de saisir le prénom et le nom. Il y a également une case à cocher contrôlant quel champ vient en premier. Si vous cochez la case, le champ « nom » apparaît avant le champ « prénom ».

Ça fonctionne presque, mais il y a un bug. Si vous remplissez le « prénom » puis cochez la case, le texte restera dans le premier champ (qui est désormais « nom »). Corrigez ça pour que le texte du champ se déplace lui aussi lorsque vous changez l'ordre des champs.

Il semble que la position de ces champs au sein du parent n'est pas suffisante. Existe-t-il un moyen de dire à React de faire correspondre les états entre les rendus ?

import { useState } from 'react';

export default function App() {
  const [reverse, setReverse] = useState(false);
  let checkbox = (
    <label>
      <input
        type="checkbox"
        checked={reverse}
        onChange={e => setReverse(e.target.checked)}
      />
      Changer l'ordre
    </label>
  );
  if (reverse) {
    return (
      <>
        <Field label="Nom" />
        <Field label="Prénom" />
        {checkbox}
      </>
    );
  } else {
    return (
      <>
        <Field label="Prénom" />
        <Field label="Nom" />
        {checkbox}
      </>
    );
  }
}

function Field({ label }) {
  const [text, setText] = useState('');
  return (
    <label>
      {label} :{' '}
      <input
        type="text"
        value={text}
        placeholder={label}
        onChange={e => setText(e.target.value)}
      />
    </label>
  );
}
label { display: block; margin: 10px 0; }

Donnez une key à chacun des composants <Field> dans les branches if et else. Ça indique à React de « faire correspondre » le bon état à chacun des <Field> même si l'ordre au sein du parent change :

import { useState } from 'react';

export default function App() {
  const [reverse, setReverse] = useState(false);
  let checkbox = (
    <label>
      <input
        type="checkbox"
        checked={reverse}
        onChange={e => setReverse(e.target.checked)}
      />
      Changer l'ordre
    </label>
  );
  if (reverse) {
    return (
      <>
        <Field key="lastName" label="Nom" />
        <Field key="firstName" label="Prénom" />
        {checkbox}
      </>
    );
  } else {
    return (
      <>
        <Field key="firstName" label="Prénom" />
        <Field key="lastName" label="Nom" />
        {checkbox}
      </>
    );
  }
}

function Field({ label }) {
  const [text, setText] = useState('');
  return (
    <label>
      {label} :{' '}
      <input
        type="text"
        value={text}
        placeholder={label}
        onChange={e => setText(e.target.value)}
      />
    </label>
  );
}
label { display: block; margin: 10px 0; }

Réinitialiser un formulaire de détails {/reset-a-detail-form/}

Voici une liste modifiable de contacts. Vous pouvez modifier les détails du contact sélectionné, puis cliquer soit sur « Enregistrer » pour les mettre à jour, soit sur « Annuler » pour annuler vos modifications.

Lorsque vous choisissez un contact différent (par exemple Alice), l'état se met à jour mais le formulaire conserve les détails du contact précédent. Corrigez ça afin que le formulaire se réinitialise lorsque le contact sélectionné change.

import { useState } from 'react';
import ContactList from './ContactList.js';
import EditContact from './EditContact.js';

export default function ContactManager() {
  const [
    contacts,
    setContacts
  ] = useState(initialContacts);
  const [
    selectedId,
    setSelectedId
  ] = useState(0);
  const selectedContact = contacts.find(c =>
    c.id === selectedId
  );

  function handleSave(updatedData) {
    const nextContacts = contacts.map(c => {
      if (c.id === updatedData.id) {
        return updatedData;
      } else {
        return c;
      }
    });
    setContacts(nextContacts);
  }

  return (
    <div>
      <ContactList
        contacts={contacts}
        selectedId={selectedId}
        onSelect={id => setSelectedId(id)}
      />
      <hr />
      <EditContact
        initialData={selectedContact}
        onSave={handleSave}
      />
    </div>
  )
}

const initialContacts = [
  { id: 0, name: 'Clara', email: 'clara@mail.com' },
  { id: 1, name: 'Alice', email: 'alice@mail.com' },
  { id: 2, name: 'Bob', email: 'bob@mail.com' }
];
export default function ContactList({
  contacts,
  selectedId,
  onSelect
}) {
  return (
    <section>
      <ul>
        {contacts.map(contact =>
          <li key={contact.id}>
            <button onClick={() => {
              onSelect(contact.id);
            }}>
              {contact.id === selectedId ?
                <b>{contact.name}</b> :
                contact.name
              }
            </button>
          </li>
        )}
      </ul>
    </section>
  );
}
import { useState } from 'react';

export default function EditContact({ initialData, onSave }) {
  const [name, setName] = useState(initialData.name);
  const [email, setEmail] = useState(initialData.email);
  return (
    <section>
      <label>
        Nom :{' '}
        <input
          type="text"
          value={name}
          onChange={e => setName(e.target.value)}
        />
      </label>
      <label>
        E-mail :{' '}
        <input
          type="email"
          value={email}
          onChange={e => setEmail(e.target.value)}
        />
      </label>
      <button onClick={() => {
        const updatedData = {
          id: initialData.id,
          name: name,
          email: email
        };
        onSave(updatedData);
      }}>
        Enregistrer
      </button>
      <button onClick={() => {
        setName(initialData.name);
        setEmail(initialData.email);
      }}>
        Annuler
      </button>
    </section>
  );
}
ul, li {
  list-style: none;
  margin: 0;
  padding: 0;
}
li { display: inline-block; }
li button {
  padding: 10px;
}
label {
  display: block;
  margin: 10px 0;
}
button {
  margin-right: 10px;
  margin-bottom: 10px;
}

Ajoutez key={selectedId} au composant EditContact. De cette façon, changer de contact réinitialisera le formulaire :

import { useState } from 'react';
import ContactList from './ContactList.js';
import EditContact from './EditContact.js';

export default function ContactManager() {
  const [
    contacts,
    setContacts
  ] = useState(initialContacts);
  const [
    selectedId,
    setSelectedId
  ] = useState(0);
  const selectedContact = contacts.find(c =>
    c.id === selectedId
  );

  function handleSave(updatedData) {
    const nextContacts = contacts.map(c => {
      if (c.id === updatedData.id) {
        return updatedData;
      } else {
        return c;
      }
    });
    setContacts(nextContacts);
  }

  return (
    <div>
      <ContactList
        contacts={contacts}
        selectedId={selectedId}
        onSelect={id => setSelectedId(id)}
      />
      <hr />
      <EditContact
        key={selectedId}
        initialData={selectedContact}
        onSave={handleSave}
      />
    </div>
  )
}

const initialContacts = [
  { id: 0, name: 'Clara', email: 'clara@mail.com' },
  { id: 1, name: 'Alice', email: 'alice@mail.com' },
  { id: 2, name: 'Bob', email: 'bob@mail.com' }
];
export default function ContactList({
  contacts,
  selectedId,
  onSelect
}) {
  return (
    <section>
      <ul>
        {contacts.map(contact =>
          <li key={contact.id}>
            <button onClick={() => {
              onSelect(contact.id);
            }}>
              {contact.id === selectedId ?
                <b>{contact.name}</b> :
                contact.name
              }
            </button>
          </li>
        )}
      </ul>
    </section>
  );
}
import { useState } from 'react';

export default function EditContact({ initialData, onSave }) {
  const [name, setName] = useState(initialData.name);
  const [email, setEmail] = useState(initialData.email);
  return (
    <section>
      <label>
        Nom :{' '}
        <input
          type="text"
          value={name}
          onChange={e => setName(e.target.value)}
        />
      </label>
      <label>
        E-mail :{' '}
        <input
          type="email"
          value={email}
          onChange={e => setEmail(e.target.value)}
        />
      </label>
      <button onClick={() => {
        const updatedData = {
          id: initialData.id,
          name: name,
          email: email
        };
        onSave(updatedData);
      }}>
        Enregistrer
      </button>
      <button onClick={() => {
        setName(initialData.name);
        setEmail(initialData.email);
      }}>
        Annuler
      </button>
    </section>
  );
}
ul, li {
  list-style: none;
  margin: 0;
  padding: 0;
}
li { display: inline-block; }
li button {
  padding: 10px;
}
label {
  display: block;
  margin: 10px 0;
}
button {
  margin-right: 10px;
  margin-bottom: 10px;
}

Effacer une image au chargement {/clear-an-image-while-its-loading/}

Lorsque vous appuyez sur « Suivante », le navigateur commence à charger l'image suivante. Cependant, comme elle est affichée dans la même balise <img>, l'image précédente reste toujours affichée par défaut jusqu'à ce que la suivante soit chargée. Ça peut être un problème s'il est important que le texte corresponde toujours à l'image. Changez ça afin que l'image précédente soit immédiatement effacée dès que vous appuyez sur « Suivante ».

Existe-t-il un moyen de dire à React de recréer le DOM plutôt que de le réutiliser ?

import { useState } from 'react';

export default function Gallery() {
  const [index, setIndex] = useState(0);
  const hasNext = index < images.length - 1;

  function handleClick() {
    if (hasNext) {
      setIndex(index + 1);
    } else {
      setIndex(0);
    }
  }

  let image = images[index];
  return (
    <>
      <button onClick={handleClick}>
        Suivante
      </button>
      <h3>
        Image {index + 1} sur {images.length}
      </h3>
      <img src={image.src} />
      <p>
        {image.place}
      </p>
    </>
  );
}

let images = [{
  place: 'Penang, Malaisie',
  src: 'https://i.imgur.com/FJeJR8M.jpg'
}, {
  place: 'Lisbonne, Portugal',
  src: 'https://i.imgur.com/dB2LRbj.jpg'
}, {
  place: 'Bilbao, Espagne',
  src: 'https://i.imgur.com/z08o2TS.jpg'
}, {
  place: 'Valparaiso, Chili',
  src: 'https://i.imgur.com/Y3utgTi.jpg'
}, {
  place: 'Canton de Schwyz, Suisse',
  src: 'https://i.imgur.com/JBbMpWY.jpg'
}, {
  place: 'Prague, République Tchèque',
  src: 'https://i.imgur.com/QwUKKmF.jpg'
}, {
  place: 'Ljubljana, Slovénie',
  src: 'https://i.imgur.com/3aIiwfm.jpg'
}];
img { width: 150px; height: 150px; }

Vous pouvez fournir une key à la balise <img>. Lorsque cette keychange, React recréera de zéro le nœud DOM <img>. Ça causera un bref flash lorsque chaque image se chargera, ce n'est donc pas quelque chose de souhaitable pour chaque image de votre appli. Cependant, ça a un intérêt si vous voulez garantir que l'image corresponde effectivement au texte.

import { useState } from 'react';

export default function Gallery() {
  const [index, setIndex] = useState(0);
  const hasNext = index < images.length - 1;

  function handleClick() {
    if (hasNext) {
      setIndex(index + 1);
    } else {
      setIndex(0);
    }
  }

  let image = images[index];
  return (
    <>
      <button onClick={handleClick}>
        Suivante
      </button>
      <h3>
        Image {index + 1} sur {images.length}
      </h3>
      <img key={image.src} src={image.src} />
      <p>
        {image.place}
      </p>
    </>
  );
}

let images = [{
  place: 'Penang, Malaisie',
  src: 'https://i.imgur.com/FJeJR8M.jpg'
}, {
  place: 'Lisbonne, Portugal',
  src: 'https://i.imgur.com/dB2LRbj.jpg'
}, {
  place: 'Bilbao, Espagne',
  src: 'https://i.imgur.com/z08o2TS.jpg'
}, {
  place: 'Valparaiso, Chili',
  src: 'https://i.imgur.com/Y3utgTi.jpg'
}, {
  place: 'Canton de Schwyz, Suisse',
  src: 'https://i.imgur.com/JBbMpWY.jpg'
}, {
  place: 'Prague, République Tchèque',
  src: 'https://i.imgur.com/QwUKKmF.jpg'
}, {
  place: 'Ljubljana, Slovénie',
  src: 'https://i.imgur.com/3aIiwfm.jpg'
}];
img { width: 150px; height: 150px; }

Corriger un état mal placé dans la liste {/fix-misplaced-state-in-the-list/}

Dans cette liste, chaque Contact a un état qui détermine si son bouton « Voir l'e-mail » a été appuyé. Appuyez sur « Voir l'e-mail » pour Alice, puis cochez la case « Afficher dans l'ordre inverse ». Vous constaterez que c'est l'e-mail de Clara qui est désormais déplié, alors que celui d'Alice — qui a été déplacée en bas de liste — apparait replié.

Corrigez ça afin que l'état déplié soit associé à chaque contact, quel que soit l'ordre d'affichage choisi.

import { useState } from 'react';
import Contact from './Contact.js';

export default function ContactList() {
  const [reverse, setReverse] = useState(false);

  const displayedContacts = [...contacts];
  if (reverse) {
    displayedContacts.reverse();
  }

  return (
    <>
      <label>
        <input
          type="checkbox"
          value={reverse}
          onChange={e => {
            setReverse(e.target.checked)
          }}
        />{' '}
        Afficher dans l’ordre inverse
      </label>
      <ul>
        {displayedContacts.map((contact, i) =>
          <li key={i}>
            <Contact contact={contact} />
          </li>
        )}
      </ul>
    </>
  );
}

const contacts = [
  { id: 0, name: 'Alice', email: 'alice@mail.com' },
  { id: 1, name: 'Bob', email: 'bob@mail.com' },
  { id: 2, name: 'Clara', email: 'clara@mail.com' }
];
import { useState } from 'react';

export default function Contact({ contact }) {
  const [expanded, setExpanded] = useState(false);
  return (
    <>
      <p><b>{contact.name}</b></p>
      {expanded &&
        <p><i>{contact.email}</i></p>
      }
      <button onClick={() => {
        setExpanded(!expanded);
      }}>
        {expanded ? 'Cacher' : 'Afficher'} l’e-mail
      </button>
    </>
  );
}
ul, li {
  list-style: none;
  margin: 0;
  padding: 0;
}
li {
  margin-bottom: 20px;
}
label {
  display: block;
  margin: 10px 0;
}
button {
  margin-right: 10px;
  margin-bottom: 10px;
}

Le problème de cet exemple, c'est qu'il utilisait l'index en tant que key :

{displayedContacts.map((contact, i) =>
  <li key={i}>

Cependant, vous voulez que l'état soit associé à chaque contact en particulier.

Utiliser l'identifiant de contact comme key corrige le problème :

import { useState } from 'react';
import Contact from './Contact.js';

export default function ContactList() {
  const [reverse, setReverse] = useState(false);

  const displayedContacts = [...contacts];
  if (reverse) {
    displayedContacts.reverse();
  }

  return (
    <>
      <label>
        <input
          type="checkbox"
          value={reverse}
          onChange={e => {
            setReverse(e.target.checked)
          }}
        />{' '}
        Afficher dans l’ordre inverse
      </label>
      <ul>
        {displayedContacts.map(contact =>
          <li key={contact.id}>
            <Contact contact={contact} />
          </li>
        )}
      </ul>
    </>
  );
}

const contacts = [
  { id: 0, name: 'Alice', email: 'alice@mail.com' },
  { id: 1, name: 'Bob', email: 'bob@mail.com' },
  { id: 2, name: 'Clara', email: 'clara@mail.com' }
];
import { useState } from 'react';

export default function Contact({ contact }) {
  const [expanded, setExpanded] = useState(false);
  return (
    <>
      <p><b>{contact.name}</b></p>
      {expanded &&
        <p><i>{contact.email}</i></p>
      }
      <button onClick={() => {
        setExpanded(!expanded);
      }}>
        {expanded ? 'Cacher' : 'Afficher'} l’e-mail
      </button>
    </>
  );
}
ul, li {
  list-style: none;
  margin: 0;
  padding: 0;
}
li {
  margin-bottom: 20px;
}
label {
  display: block;
  margin: 10px 0;
}
button {
  margin-right: 10px;
  margin-bottom: 10px;
}

L'état est associé à la position dans l'arbre. Une key vous permet de spécifier une position nommée au lieu de vous fier à l'ordre des éléments.