Choisir la structure de l’état

Bien structurer l’état peut faire toute la différence entre un composant agréable à modifier et déboguer, et un composant qui est une source constante de bugs. Voici des conseils que vous devriez prendre en compte pour structurer vos états.

Vous allez apprendre

  • Quand utiliser une vs. plusieurs variables d’état
  • Les pièges à éviter en organisant l’état
  • Comment résoudre les problèmes courants de structure de l’état

Principes de structuration d’état

Quand vous créez un composant qui contient des états, vous devez faire des choix sur le nombre de variables d’état à utiliser et la forme de leurs données. Même s’il est possible d’écrire des programmes corrects avec une structure d’état sous-optimale, il y a quelques principes qui peuvent vous guider pour faire de meilleurs choix :

  1. Regroupez les états associés. Si vous mettez tout le temps à jour plusieurs variables d’état à la fois, essayez de les fusionner en une seule variable d’état.
  2. Évitez les contradictions dans l’état. Quand l’état est structuré de sorte que plusieurs parties d’état puissent être contradictoires, des erreurs peuvent survenir. Essayez d’éviter ça.
  3. Évitez les états redondants. Si vous pouvez calculer des informations à partir des props du composant ou de ses variables d’état existantes pendant le rendu, vous ne devriez pas mettre ces informations dans un état du composant.
  4. Évitez la duplication d’états. Lorsque la même donnée est dupliquée entre plusieurs variables d’état ou dans des objets imbriqués, il devient difficile de les garder synchronisées. Réduisez la duplication dans toute la mesure du possible.
  5. Évitez les états fortement imbriqués. Un état fortement hiérarchisé n’est pas très pratique à mettre à jour. Quand c’est possible, priorisez une structure d’état plate.

Ces principes visent à rendre l’état simple à actualiser sans créer d’erreurs. Retirer les données redondantes et dupliquées de l’état aide à s’assurer que toutes ses parties restent synchronisées. C’est un peu comme un ingénieur de bases de données qui souhaite « normaliser » la structure de la base de données pour réduire les risques de bugs. Pour paraphraser Albert Einstein : « Faites que votre état soit le plus simple possible — mais pas plus simple. »

Maintenant voyons comment ces principes s’appliquent concrètement.

Vous hésitez peut-être parfois entre utiliser une ou plusieurs variables d’état.

Devriez-vous faire ça ?

const [x, setX] = useState(0);
const [y, setY] = useState(0);

Ou ça ?

const [position, setPosition] = useState({ x: 0, y: 0 });

Techniquement, les deux approches sont possibles. Mais si deux variables d’état changent toujours ensemble, ce serait une bonne idée de les réunir en une seule variable d’état. Vous n’oublierez ainsi pas ensuite de les garder synchronisées, comme dans cet exemple où les mouvements du curseur mettent à jour les deux coordonnées du point rouge.

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

Une autre situation dans laquelle vous pouvez regrouper des données dans un objet ou une liste, c’est lorsque vous ne savez pas à l’avance de combien d’éléments d’état vous aurez besoin. Par exemple, c’est utile pour un formulaire dans lequel l’utilisateur peut ajouter des champs personnalisés.

Piège

Si votre variable d’état est un objet, souvenez-vous que vous ne pouvez pas mettre à jour qu’un seul champ sans explicitement copier les autres champs. Par exemple, vous ne pouvez pas faire setPosition({ x: 100 }) dans l’exemple ci-dessus car il n’y aurait plus du tout la propriété y ! Au lieu de ça, si vous vouliez définir x tout seul, soit vous feriez setPosition({ ...position, x: 100 }), soit vous découperiez l’information en deux variables d’état et feriez setX(100).

Évitez les contradictions dans l’état

Voici un questionnaire de satisfaction d’hôtel avec les variables d’état isSending et isSent :

import { useState } from 'react';

export default function FeedbackForm() {
  const [text, setText] = useState('');
  const [isSending, setIsSending] = useState(false);
  const [isSent, setIsSent] = useState(false);

  async function handleSubmit(e) {
    e.preventDefault();
    setIsSending(true);
    await sendMessage(text);
    setIsSending(false);
    setIsSent(true);
  }

  if (isSent) {
    return <h1>Merci pour votre retour !</h1>
  }

  return (
    <form onSubmit={handleSubmit}>
      <p>Comment était votre séjour au Poney Fringant ?</p>
      <textarea
        disabled={isSending}
        value={text}
        onChange={e => setText(e.target.value)}
      />
      <br />
      <button
        disabled={isSending}
        type="submit"
      >
        Envoyer
      </button>
      {isSending && <p>Envoi...</p>}
    </form>
  );
}

// Prétend envoyer un message.
function sendMessage(text) {
  return new Promise(resolve => {
    setTimeout(resolve, 2000);
  });
}

Même si ce code marche, il laisse la place à des états « impossibles ». Par exemple, si vous oubliez d’appeler setIsSent et setIsSending ensemble, vous pouvez finir dans une situation où les deux variables isSending et isSent sont à true au même moment. Plus votre composant est complexe, plus il est dur de comprendre ce qu’il s’est passé.

Comme isSending et isSent ne doivent jamais être à true au même moment, il est préférable de les remplacer par une variable d’état status qui peut prendre l’un des trois états valides : 'typing' (initial), 'sending', et 'sent' :

import { useState } from 'react';

export default function FeedbackForm() {
  const [text, setText] = useState('');
  const [status, setStatus] = useState('typing');

  async function handleSubmit(e) {
    e.preventDefault();
    setStatus('sending');
    await sendMessage(text);
    setStatus('sent');
  }

  const isSending = status === 'sending';
  const isSent = status === 'sent';

  if (isSent) {
    return <h1>Merci pour votre retour !</h1>
  }

  return (
    <form onSubmit={handleSubmit}>
      <p>Comment était votre séjour au Poney Fringant ?</p>
      <textarea
        disabled={isSending}
        value={text}
        onChange={e => setText(e.target.value)}
      />
      <br />
      <button
        disabled={isSending}
        type="submit"
      >
        Envoyer
      </button>
      {isSending && <p>Envoi...</p>}
    </form>
  );
}

// Prétend envoyer un message.
function sendMessage(text) {
  return new Promise(resolve => {
    setTimeout(resolve, 2000);
  });
}

Vous pouvez toujours déclarer quelques constantes pour plus de lisibilité :

const isSending = status === 'sending';
const isSent = status === 'sent';

Mais ce ne sont pas des variables d’état, vous n’avez donc pas à vous soucier de leur désynchronisation.

Évitez les états redondants

Si vous pouvez calculer certaines informations depuis les props d’un composant ou une de ses variables d’état existantes pendant le rendu, vous ne devez pas mettre ces informations dans l’état du composant

Par exemple, prenez ce formulaire. Il marche, mais pouvez-vous y trouver un état redondant ?

import { useState } from 'react';

export default function Form() {
  const [firstName, setFirstName] = useState('');
  const [lastName, setLastName] = useState('');
  const [fullName, setFullName] = useState('');

  function handleFirstNameChange(e) {
    setFirstName(e.target.value);
    setFullName(e.target.value + ' ' + lastName);
  }

  function handleLastNameChange(e) {
    setLastName(e.target.value);
    setFullName(firstName + ' ' + e.target.value);
  }

  return (
    <>
      <h2>Enregistrons votre arrivée</h2>
      <label>
        Prénom :{' '}
        <input
          value={firstName}
          onChange={handleFirstNameChange}
        />
      </label>
      <label>
        Nom :{' '}
        <input
          value={lastName}
          onChange={handleLastNameChange}
        />
      </label>
      <p>
        Votre billet sera au nom de : <b>{fullName}</b>
      </p>
    </>
  );
}

Ce formulaire possède trois variables d’état : firstName, lastName et fullName. Cependant, fullName est redondant. Vous pouvez toujours calculer fullName depuis firstName et lastName pendant le rendu, donc retirez-le de l’état.

Voici comment vous pouvez faire :

import { useState } from 'react';

export default function Form() {
  const [firstName, setFirstName] = useState('');
  const [lastName, setLastName] = useState('');

  const fullName = firstName + ' ' + lastName;

  function handleFirstNameChange(e) {
    setFirstName(e.target.value);
  }

  function handleLastNameChange(e) {
    setLastName(e.target.value);
  }

  return (
    <>
      <h2>Enregistrons votre arrivée</h2>
      <label>
        Prénom :{' '}
        <input
          value={firstName}
          onChange={handleFirstNameChange}
        />
      </label>
      <label>
        Nom :{' '}
        <input
          value={lastName}
          onChange={handleLastNameChange}
        />
      </label>
      <p>
        Votre billet sera au nom de : <b>{fullName}</b>
      </p>
    </>
  );
}

Ici, fullName n’est pas une variable d’état. Elle est plutôt évaluée pendant le rendu :

const fullName = firstName + ' ' + lastName;

Par conséquent, les gestionnaires de changement n’auront rien à faire pour le mettre à jour. Lorsque vous appelez setFirstName ou setLastName, vous déclenchez un nouveau rendu, et le prochain fullName sera calculé à partir des nouvelles données.

En détail

Ne dupliquez pas les props dans l’état

Un exemple commun d’état redondant recourt à ce genre de code :

function Message({ messageColor }) {
const [color, setColor] = useState(messageColor);
}

Ici, la prop messageColor est passée comme valeur initiale de la variable d’état color. Le problème est que si le composant parent transmet une valeur différente dans messageColor plus tard (par exemple, 'red' au lieu de 'blue'), la variable d’état color ne sera pas mise à jour ! L’état est seulement initialisé lors du rendu initial.

C’est pourquoi la “duplication” de certaines props dans des variables d’état peut être déroutante. Utilisez de préférence directement la prop messageColor dans votre code. Si vous voulez lui donner un nom plus court, utilisez une constante :

function Message({ messageColor }) {
const color = messageColor;
}

De cette manière, le composant ne sera pas désynchronisé avec la prop qui lui aura été transmise par le composant parent.

« Dupliquer » les props dans l’état n’est pertinent que lorsque vous voulez ignorer toutes les mises à jour d’une certaine prop. Par convention, ajoutez initial ou default au début du nom de la prop pour préciser que ses nouvelles valeurs seront ignorées :

function Message({ initialColor }) {
// La variable d’état `color` contient la *première* valeur de `initialColor`.
// Les changements ultérieurs de la prop `initialColor` seront ignorés.
const [color, setColor] = useState(initialColor);
}

Évitez la duplication d’états

Ce composant de carte de menu vous permet de choisir un seul en-cas de voyage parmi plusieurs :

import { useState } from 'react';

const initialItems = [
  { title: 'bretzels', id: 0 },
  { title: 'algues croustillantes', id: 1 },
  { title: 'paquet de princes', id: 2 },
];

export default function Menu() {
  const [items, setItems] = useState(initialItems);
  const [selectedItem, setSelectedItem] = useState(
    items[0]
  );

  return (
    <>
      <h2>Quel est votre goûter de voyage ?</h2>
      <ul>
        {items.map(item => (
          <li key={item.id}>
            {item.title}
            {' '}
            <button onClick={() => {
              setSelectedItem(item);
            }}>Choisissez</button>
          </li>
        ))}
      </ul>
      <p>Vous avez choisi {selectedItem.title}.</p>
    </>
  );
}

À ce stade, il stocke l’élément selectionné en tant qu’objet dans la variable d’état selectedItem. Cependant, ce n’est pas optimal : le contenu de selectedItem est le même objet que l’un des éléments de la liste items. Ça signifie que les informations relatives à l’élément sont dupliquées à deux endroits.

Pourquoi est-ce un problème ? Rendons chaque objet modifiable :

import { useState } from 'react';

const initialItems = [
  { title: 'bretzels', id: 0 },
  { title: 'algues croustillantes', id: 1 },
  { title: 'paquet de princes', id: 2 },
];

export default function Menu() {
  const [items, setItems] = useState(initialItems);
  const [selectedItem, setSelectedItem] = useState(
    items[0]
  );

  function handleItemChange(id, e) {
    setItems(items.map(item => {
      if (item.id === id) {
        return {
          ...item,
          title: e.target.value,
        };
      } else {
        return item;
      }
    }));
  }

  return (
    <>
      <h2>Quel est votre goûter de voyage ?</h2>
      <ul>
        {items.map((item, index) => (
          <li key={item.id}>
            <input
              value={item.title}
              onChange={e => {
                handleItemChange(item.id, e)
              }}
            />
            {' '}
            <button onClick={() => {
              setSelectedItem(item);
            }}>Choisissez</button>
          </li>
        ))}
      </ul>
      <p>Vous avez choisi {selectedItem.title}.</p>
    </>
  );
}

Remarquez que si vous cliquez d’abord sur « Choisissez » un élément puis que vous le modifiez, le champ se met à jour, mais le libellé en bas reste inchangé. C’est parce que vous avez dupliqué l’état, et que vous avez oublié de mettre à jour selectedItem.

Même si vous pourriez également mettre à jour selectedItem, une solution plus simple consiste à supprimer la duplication. Dans cet exemple, au lieu d’un objet selectedItem (ce qui crée une duplication des éléments dans items), vous gardez le selectedId dans l’état, puis obtenez le selectedItem en cherchant dans la liste items un élément avec cet ID :

import { useState } from 'react';

const initialItems = [
  { title: 'bretzels', id: 0 },
  { title: 'algues croustillantes', id: 1 },
  { title: 'paquet de princes', id: 2 },
];

export default function Menu() {
  const [items, setItems] = useState(initialItems);
  const [selectedId, setSelectedId] = useState(0);

  const selectedItem = items.find(item =>
    item.id === selectedId
  );

  function handleItemChange(id, e) {
    setItems(items.map(item => {
      if (item.id === id) {
        return {
          ...item,
          title: e.target.value,
        };
      } else {
        return item;
      }
    }));
  }

  return (
    <>
      <h2>Quel est votre goûter de voyage ?</h2>
      <ul>
        {items.map((item, index) => (
          <li key={item.id}>
            <input
              value={item.title}
              onChange={e => {
                handleItemChange(item.id, e)
              }}
            />
            {' '}
            <button onClick={() => {
              setSelectedId(item.id);
            }}>Choisissez</button>
          </li>
        ))}
      </ul>
      <p>Vous avez choisi {selectedItem.title}.</p>
    </>
  );
}

L’état était dupliqué de cette façon :

  • items = [{ id: 0, title: 'pretzels'}, ...]
  • selectedItem = {id: 0, title: 'pretzels'}

Mais après nos changements, il a la structure suivante :

  • items = [{ id: 0, title: 'pretzels'}, ...]
  • selectedId = 0

La duplication a disparu, et vous ne conservez que l’état essentiel !

Maintenant si vous modifiez l’élément sélectionné, le message en dessous sera mis à jour immédiatement. C’est parce que setItems déclenche un nouveau rendu, et items.find(...) trouve l’élément dont le titre a été mis à jour. Il n’est pas nécessaire de conserver l’objet sélectionné dans l’état, car seul l’ID sélectionné est essentiel. Le reste peut être calculé lors du rendu.

Évitez les états fortement imbriqués

Imaginez un plan de voyage composé de planètes, de continents et de pays. Vous pourriez être tenté·e de structurer son état à l’aide de listes et d’objets imbriqués, comme dans cet exemple :

export const initialTravelPlan = {
  id: 0,
  title: '(Root)',
  childPlaces: [{
    id: 1,
    title: 'Terre',
    childPlaces: [{
      id: 2,
      title: 'Afrique',
      childPlaces: [{
        id: 3,
        title: 'Botswana',
        childPlaces: []
      }, {
        id: 4,
        title: 'Egypte',
        childPlaces: []
      }, {
        id: 5,
        title: 'Kenya',
        childPlaces: []
      }, {
        id: 6,
        title: 'Madagascar',
        childPlaces: []
      }, {
        id: 7,
        title: 'Maroc',
        childPlaces: []
      }, {
        id: 8,
        title: 'Nigéria',
        childPlaces: []
      }, {
        id: 9,
        title: 'Afrique du Sud',
        childPlaces: []
      }]
    }, {
      id: 10,
      title: 'Amérique',
      childPlaces: [{
        id: 11,
        title: 'Argentine',
        childPlaces: []
      }, {
        id: 12,
        title: 'Brésil',
        childPlaces: []
      }, {
        id: 13,
        title: 'Barbade',
        childPlaces: []
      }, {
        id: 14,
        title: 'Canada',
        childPlaces: []
      }, {
        id: 15,
        title: 'Jamaïque',
        childPlaces: []
      }, {
        id: 16,
        title: 'Mexique',
        childPlaces: []
      }, {
        id: 17,
        title: 'Trinité-et-Tobté-et-childPlaces: []
      }, {
        id: 18,
        title: 'Venezuela',
        childPlaces: []
      }]
    }, {
      id: 19,
      title: 'Asie',
      childPlaces: [{
        id: 20,
        title: 'Chine',
        childPlaces: []
      }, {
        id: 21,
        title: 'Inde',
        childPlaces: []
      }, {
        id: 22,
        title: 'Singapour',
        childPlaces: []
      }, {
        id: 23,
        title: 'Corée du Sud',
        childPlaces: []
      }, {
        id: 24,
        title: 'Thaïlande',
        childPlaces: []
      }, {
        id: 25,
        title: 'Viêt Nam',
        childPlaces: []
      }]
    }, {
      id: 26,
      title: 'Europe',
      childPlaces: [{
        id: 27,
        title: 'Croatie',
        childPlaces: [],
      }, {
        id: 28,
        title: 'France',
        childPlaces: [],
      }, {
        id: 29,
        title: 'Allemagne',
        childPlaces: [],
      }, {
        id: 30,
        title: 'Italie',
        childPlaces: [],
      }, {
        id: 31,
        title: 'Portugal',
        childPlaces: [],
      }, {
        id: 32,
        title: 'Espagne',
        childPlaces: [],
      }, {
        id: 33,
        title: 'Turquie',
        childPlaces: [],
      }]
    }, {
      id: 34,
      title: 'Océanie',
      childPlaces: [{
        id: 35,
        title: 'Australie',
        childPlaces: [],
      }, {
        id: 36,
        title: 'Bora Bora (Polynésie française)',
        childPlaces: [],
      }, {
        id: 37,
        title: 'Île de Pâques (Chili)',
        childPlaces: [],
      }, {
        id: 38,
        title: 'Fidji',
        childPlaces: [],
      }, {
        id: 39,
        title: 'Hawaï (États-Unis)',
        childPlaces: [],
      }, {
        id: 40,
        title: 'Nouvelle-Zélande',
        childPlaces: [],
      }, {
        id: 41,
        title: 'Vanuatu',
        childPlaces: [],
      }]
    }]
  }, {
    id: 42,
    title: 'Lune',
    childPlaces: [{
      id: 43,
      title: 'Rheita',
      childPlaces: []
    }, {
      id: 44,
      title: 'Piccolomini',
      childPlaces: []
    }, {
      id: 45,
      title: 'Tycho',
      childPlaces: []
    }]
  }, {
    id: 46,
    title: 'Mars',
    childPlaces: [{
      id: 47,
      title: 'Corn Town',
      childPlaces: []
    }, {
      id: 48,
      title: 'Green Hill',
      childPlaces: []
    }]
  }]
};

Imaginons maintenant que vous souhaitiez ajouter un bouton pour supprimer un lieu que vous avez déjà visité. Comment procéder ? La mise à jour d’un état imbriqué implique de faire des copies des objets en remontant depuis la partie qui a changé. Supprimer un lieu profondément imbriqué consisterait à copier tous les niveaux supérieurs. Un tel code peut être très long.

Si l’état est trop imbriqué pour être mis à jour facilement, envisagez de « l’aplatir ». Voici une façon de restructurer ces données. Au lieu d’une structure arborescente où chaque lieu possède une liste de ses lieux enfants, chaque lieu peut posséder une liste des ID de ses lieux enfants. Vous pouvez alors stocker une table de correspondance entre chaque ID de lieu et le lieu correspondant.

Cette restructuration des données pourrait vous rappeler une table de base de données :

export const initialTravelPlan = {
  0: {
    id: 0,
    title: '(Root)',
    childIds: [1, 42, 46],
  },
  1: {
    id: 1,
    title: 'Terre',
    childIds: [2, 10, 19, 26, 34]
  },
  2: {
    id: 2,
    title: 'Afrique',
    childIds: [3, 4, 5, 6 , 7, 8, 9]
  },
  3: {
    id: 3,
    title: 'Botswana',
    childIds: []
  },
  4: {
    id: 4,
    title: 'Egypte',
    childIds: []
  },
  5: {
    id: 5,
    title: 'Kenya',
    childIds: []
  },
  6: {
    id: 6,
    title: 'Madagascar',
    childIds: []
  },
  7: {
    id: 7,
    title: 'Maroc',
    childIds: []
  },
  8: {
    id: 8,
    title: 'Nigéria',
    childIds: []
  },
  9: {
    id: 9,
    title: 'Afrique du Sud',
    childIds: []
  },
  10: {
    id: 10,
    title: 'Amerique',
    childIds: [11, 12, 13, 14, 15, 16, 17, 18],
  },
  11: {
    id: 11,
    title: 'Argentine',
    childIds: []
  },
  12: {
    id: 12,
    title: 'Brésil',
    childIds: []
  },
  13: {
    id: 13,
    title: 'Barbade',
    childIds: []
  },
  14: {
    id: 14,
    title: 'Canada',
    childIds: []
  },
  15: {
    id: 15,
    title: 'Jamaïque',
    childIds: []
  },
  16: {
    id: 16,
    title: 'Mexique',
    childIds: []
  },
  17: {
    id: 17,
    title: 'Trinité-et-Tobago',
    childIds: []
  },
  18: {
    id: 18,
    title: 'Venezuela',
    childIds: []
  },
  19: {
    id: 19,
    title: 'Asie',
    childIds: [20, 21, 22, 23, 24, 25],
  },
  20: {
    id: 20,
    title: 'Chine',
    childIds: []
  },
  21: {
    id: 21,
    title: 'India',
    childIds: []
  },
  22: {
    id: 22,
    title: 'Singapour',
    childIds: []
  },
  23: {
    id: 23,
    title: 'Corée du Sud',
    childIds: []
  },
  24: {
    id: 24,
    title: 'Thaïlande',
    childIds: []
  },
  25: {
    id: 25,
    title: 'Viêt Nam',
    childIds: []
  },
  26: {
    id: 26,
    title: 'Europe',
    childIds: [27, 28, 29, 30, 31, 32, 33],
  },
  27: {
    id: 27,
    title: 'Croatie',
    childIds: []
  },
  28: {
    id: 28,
    title: 'France',
    childIds: []
  },
  29: {
    id: 29,
    title: 'Allemagne',
    childIds: []
  },
  30: {
    id: 30,
    title: 'Italie',
    childIds: []
  },
  31: {
    id: 31,
    title: 'Portugal',
    childIds: []
  },
  32: {
    id: 32,
    title: 'Spain',
    childIds: []
  },
  33: {
    id: 33,
    title: 'Turquie',
    childIds: []
  },
  34: {
    id: 34,
    title: 'Océanie',
    childIds: [35, 36, 37, 38, 39, 40, 41],
  },
  35: {
    id: 35,
    title: 'Australie',
    childIds: []
  },
  36: {
    id: 36,
    title: 'Bora Bora (Polynésie française)',
    childIds: []
  },
  37: {
    id: 37,
    title: 'Île de Pâques (Chili)',
    childIds: []
  },
  38: {
    id: 38,
    title: 'Fidji',
    childIds: []
  },
  39: {
    id: 40,
    title: 'Hawaï (USA)',
    childIds: []
  },
  40: {
    id: 40,
    title: 'Nouvelle-Zélande',
    childIds: []
  },
  41: {
    id: 41,
    title: 'Vanuatu',
    childIds: []
  },
  42: {
    id: 42,
    title: 'Lune',
    childIds: [43, 44, 45]
  },
  43: {
    id: 43,
    title: 'Rheita',
    childIds: []
  },
  44: {
    id: 44,
    title: 'Piccolomini',
    childIds: []
  },
  45: {
    id: 45,
    title: 'Tycho',
    childIds: []
  },
  46: {
    id: 46,
    title: 'Mars',
    childIds: [47, 48]
  },
  47: {
    id: 47,
    title: 'Corn Town',
    childIds: []
  },
  48: {
    id: 48,
    title: 'Green Hill',
    childIds: []
  }
};

Maintenant que l’état est « plat » (on pourrait aussi dire « normalisé »), mettre à jour des éléments imbriqués devient plus simple.

Désormais, afin d’enlever un lieu, vous n’avez besoin de mettre à jour que deux niveaux d’état :

  • La version à jour de son lieu parent devrait exclure l’ID supprimé de sa liste childIds.
  • La version à jour de la « table » racine d’objets doit inclure la version à jour du lieu parent.

Voici un exemple de comment vous pourriez procéder :

import { useState } from 'react';
import { initialTravelPlan } from './places.js';

export default function TravelPlan() {
  const [plan, setPlan] = useState(initialTravelPlan);

  function handleComplete(parentId, childId) {
    const parent = plan[parentId];
    // Créer une nouvelle version du lieu parent
    // qui n’inclut pas l’ID de son enfant.
    const nextParent = {
      ...parent,
      childIds: parent.childIds
        .filter(id => id !== childId)
    };
    // Mettre à jour l’état de l’objet d’origine...
    setPlan({
      ...plan,
      // ...pour qu’il ait le parent à jour
      [parentId]: nextParent
    });
  }

  const root = plan[0];
  const planetIds = root.childIds;
  return (
    <>
      <h2>Lieux à visiter</h2>
      <ol>
        {planetIds.map(id => (
          <PlaceTree
            key={id}
            id={id}
            parentId={0}
            placesById={plan}
            onComplete={handleComplete}
          />
        ))}
      </ol>
    </>
  );
}

function PlaceTree({ id, parentId, placesById, onComplete }) {
  const place = placesById[id];
  const childIds = place.childIds;
  return (
    <li>
      {place.title}
      <button onClick={() => {
        onComplete(parentId, id);
      }}>
        C’est fait
      </button>
      {childIds.length > 0 &&
        <ol>
          {childIds.map(childId => (
            <PlaceTree
              key={childId}
              id={childId}
              parentId={id}
              placesById={placesById}
              onComplete={onComplete}
            />
          ))}
        </ol>
      }
    </li>
  );
}

Vous pouvez imbriquer des états autant que vous le souhaitez, mais les rendre « plats » peut résoudre de nombreux problèmes. Ça facilite la mise à jour de l’état, et ça permet de s’assurer qu’il n’y a pas de duplication dans les différentes parties d’un objet imbriqué.

En détail

Consommer moins de mémoire

Idéalement, vous devriez également enlever les éléments supprimés (et leurs enfants !) depuis l’objet « table » pour consommer moins de mémoire. C’est ce que fait cette version. Elle utilise également Immer pour rendre la logique de mise à jour plus concise.

{
  "dependencies": {
    "immer": "1.7.3",
    "react": "latest",
    "react-dom": "latest",
    "react-scripts": "latest",
    "use-immer": "0.5.1"
  },
  "scripts": {
    "start": "react-scripts start",
    "build": "react-scripts build",
    "test": "react-scripts test --env=jsdom",
    "eject": "react-scripts eject"
  },
  "devDependencies": {}
}

Parfois, vous pouvez aussi réduire l’imbrication des états en déplaçant une partie de l’état imbriqué dans les composants enfants. C’est bien adapté aux états éphémères de l’UI qui n’ont pas besoin d’être stockés, comme le fait de savoir si un élément est survolé.

En résumé

  • Si deux variables d’état sont toujours mises à jour ensemble, envisagez de les fusionner en une seule.
  • Choisissez soigneusement vos variables d’état pour éviter de créer des états « impossibles ».
  • Structurez votre état de manière à réduire les risques d’erreur lors de sa mise à jour.
  • Evitez les états dupliqués et redondants afin de ne pas avoir à les synchroniser.
  • Ne mettez pas de props dans un état à moins que vous ne vouliez spécifiquement empêcher les mises à jour.
  • Pour les interactions telles que la sélection d’élément, conservez l’ID ou l’index dans l’état au lieu de référencer l’objet lui-même.
  • Si la mise à jour d’un état profondément imbriqué est compliquée, essayez de l’aplatir.

Défi 1 sur 4 ·
Réparer un composant qui ne s’actualise pas

Ce composant Clock reçoit deux props : color et time. Lorsque vous sélectionnez une couleur différente dans la boîte de sélection, le composant Clock reçoit une prop color différente depuis son composant parent. Cependant, la couleur affichée n’est pas mise à jour. Pourquoi ? Corrigez le problème.

import { useState } from 'react';

export default function Clock(props) {
  const [color, setColor] = useState(props.color);
  return (
    <h1 style={{ color: color }}>
      {props.time}
    </h1>
  );
}