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. Quand la même donnée est dupliquée entre plusieurs variables d’état ou dans des objets imbriqués, il est difficile de les garder synchronisées. Réduisez la duplication quand vous le pouvez.
  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 variable 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 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 Vagabond ?</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 de statut 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 Vagabond ?</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 questionnaire. 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 ticket sera délivré à : <b>{fullName}</b>
      </p>
    </>
  );
}

Ce questionnaire 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 ticket sera délivré à : <b>{fullName}</b>
      </p>
    </>
  );
}

Ici, fullName n’est pas une variable d’état. À la place, elle est é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 est un code de ce type :

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, 'rouge' au lieu de 'bleu'), la variable d’état color ne sera pas mise à jour ! L’état est seulement initialisé durant le premier rendu.

C’est pourquoi la “duplication” de certaines props dans des variables d’état peut mener à la confusion. À la place, utilisez la prop messageColor directement 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 transmise par le composant parent. “Dupliquer” les props dans l’état fait sens uniquement quand 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 prochains changements à 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);
            }}>Choisir</button>
          </li>
        ))}
      </ul>
      <p>Vous avez choisi {selectedItem.title}.</p>
    </>
  );
}

Actuellement, 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. Cela 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);
            }}>Choisir</button>
          </li>
        ))}
      </ul>
      <p>Vous avez choisi {selectedItem.title}.</p>
    </>
  );
}

Remarquez que si vous cliquez d’abord sur “Choisir” un élément puis que vous le modifiez, la saisie se met à jour, mais le label en bas est différent des modifications. C’est parce que vous avez un dupliqué l’état, et vous avez oublié de mettre à jour selectedItem.

Bien que 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 pour 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);
            }}>Choisir</button>
          </li>
        ))}
      </ul>
      <p>Vous avez choisi {selectedItem.title}.</p>
    </>
  );
}

(Vous pouvez également garder l’index sélectionné dans l’état.)

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

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

Mais après les changements, il est ainsi :

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

La duplication est terminée, et vous ne conservez que l’état essentiel !

Maintenant si vous modifiez l’élément selectionné, 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 selectionné 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és 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: 'Trinidad et Tobago',
        childPlaces: []
      }, {
        id: 18,
        title: 'Venezuela',
        childPlaces: []
      }]
    }, {
      id: 19,
      title: 'Asie',
      childPlaces: [{
        id: 20,
        title: 'Chine',
        childPlaces: []
      }, {
        id: 21,
        title: 'Hong Kong',
        childPlaces: []
      }, {
        id: 22,
        title: 'Inde',
        childPlaces: []
      }, {
        id: 23,
        title: 'Singapour',
        childPlaces: []
      }, {
        id: 24,
        title: 'Corée du Sud',
        childPlaces: []
      }, {
        id: 25,
        title: 'Thaïlande',
        childPlaces: []
      }, {
        id: 26,
        title: 'Vietnam',
        childPlaces: []
      }]
    }, {
      id: 27,
      title: 'Europe',
      childPlaces: [{
        id: 28,
        title: 'Croatie',
        childPlaces: [],
      }, {
        id: 29,
        title: 'France',
        childPlaces: [],
      }, {
        id: 30,
        title: 'Allemagne',
        childPlaces: [],
      }, {
        id: 31,
        title: 'Italie',
        childPlaces: [],
      }, {
        id: 32,
        title: 'Portugal',
        childPlaces: [],
      }, {
        id: 33,
        title: 'Espagne',
        childPlaces: [],
      }, {
        id: 34,
        title: 'Turquie',
        childPlaces: [],
      }]
    }, {
      id: 35,
      title: 'Océanie',
      childPlaces: [{
        id: 36,
        title: 'Australie',
        childPlaces: [],
      }, {
        id: 37,
        title: 'Bora Bora (Polynésie Française)',
        childPlaces: [],
      }, {
        id: 38,
        title: 'ïle de Pâques (Chili)',
        childPlaces: [],
      }, {
        id: 39,
        title: 'Fidji',
        childPlaces: [],
      }, {
        id: 40,
        title: 'Hawaï (USA)',
        childPlaces: [],
      }, {
        id: 41,
        title: 'Nouvelle Zélande',
        childPlaces: [],
      }, {
        id: 42,
        title: 'Vanuatu',
        childPlaces: [],
      }]
    }]
  }, {
    id: 43,
    title: 'Lune',
    childPlaces: [{
      id: 44,
      title: 'Rheita',
      childPlaces: []
    }, {
      id: 45,
      title: 'Piccolomini',
      childPlaces: []
    }, {
      id: 46,
      title: 'Tycho',
      childPlaces: []
    }]
  }, {
    id: 47,
    title: 'Mars',
    childPlaces: [{
      id: 48,
      title: 'Corn Town',
      childPlaces: []
    }, {
      id: 49,
      title: 'Green Hill',
      childPlaces: []      
    }]
  }]
};

Imaginons maintenant que vous souhaitez 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 jusqu’à la partie qui a changé. Supprimer un lieu imbriqué profondément consisterait à copier tout son chemin d’emplacement. Un tel code peut être très long.

Si l’état est trop imbriqué pour être mis à jour facilement, envisagez de le rendre “plat”. Voici une façon de restructurer ces données. Au lieu d’une structure arborescente où chaque lieu possède une liste des emplacements de ses enfants, chaque endroit peut posséder une liste de l’ID de ses lieux enfants. Vous pouvez ensuite stocker une 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, 43, 47],
  },
  1: {
    id: 1,
    title: 'Terre',
    childIds: [2, 10, 19, 27, 35]
  },
  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: 'Trinidad et Tobago',
    childIds: []
  },
  18: {
    id: 18,
    title: 'Venezuela',
    childIds: []
  },
  19: {
    id: 19,
    title: 'Asie',
    childIds: [20, 21, 22, 23, 24, 25, 26],   
  },
  20: {
    id: 20,
    title: 'Chine',
    childIds: []
  },
  21: {
    id: 21,
    title: 'Hong Kong',
    childIds: []
  },
  22: {
    id: 22,
    title: 'Inde',
    childIds: []
  },
  23: {
    id: 23,
    title: 'Singapour',
    childIds: []
  },
  24: {
    id: 24,
    title: 'Corée du Sud',
    childIds: []
  },
  25: {
    id: 25,
    title: 'Thaïlande',
    childIds: []
  },
  26: {
    id: 26,
    title: 'Vietnam',
    childIds: []
  },
  27: {
    id: 27,
    title: 'Europe',
    childIds: [28, 29, 30, 31, 32, 33, 34],   
  },
  28: {
    id: 28,
    title: 'Croatie',
    childIds: []
  },
  29: {
    id: 29,
    title: 'France',
    childIds: []
  },
  30: {
    id: 30,
    title: 'Allemagne',
    childIds: []
  },
  31: {
    id: 31,
    title: 'Italie',
    childIds: []
  },
  32: {
    id: 32,
    title: 'Portugal',
    childIds: []
  },
  33: {
    id: 33,
    title: 'Espagne',
    childIds: []
  },
  34: {
    id: 34,
    title: 'Turquie',
    childIds: []
  },
  35: {
    id: 35,
    title: 'Océanie',
    childIds: [36, 37, 38, 39, 40, 41, 42],   
  },
  36: {
    id: 36,
    title: 'Australie',
    childIds: []
  },
  37: {
    id: 37,
    title: 'Bora Bora (Polynésie Française)',
    childIds: []
  },
  38: {
    id: 38,
    title: 'Ile de Pâques (Chili)',
    childIds: []
  },
  39: {
    id: 39,
    title: 'Fidji',
    childIds: []
  },
  40: {
    id: 40,
    title: 'Hawaï (USA)',
    childIds: []
  },
  41: {
    id: 41,
    title: 'Nouvelle Zélande',
    childIds: []
  },
  42: {
    id: 42,
    title: 'Vanuatu',
    childIds: []
  },
  43: {
    id: 43,
    title: 'Lune',
    childIds: [44, 45, 46]
  },
  44: {
    id: 44,
    title: 'Rheita',
    childIds: []
  },
  45: {
    id: 45,
    title: 'Piccolomini',
    childIds: []
  },
  46: {
    id: 46,
    title: 'Tycho',
    childIds: []
  },
  47: {
    id: 47,
    title: 'Mars',
    childIds: [48, 49]
  },
  48: {
    id: 48,
    title: 'Corn Town',
    childIds: []
  },
  49: {
    id: 49,
    title: 'Green Hill',
    childIds: []
  }
};

Maintenant que l’état est “plat” (aussi dit “normalisé”), actualiser des éléments imbriqués devient plus simple

Désormais, afin d’enlever un lieu vous avez besoin d’actualiser seulement deux niveaux d’état:

  • La version actualisée de son lieu parent devrait exclure l’ID supprimé de sa liste childIds.
  • La version actualisée de la “table” d’objet root doit inclure la version mise à jour du lieu parent.

Voici un exemple de commment vous pourriez commencer:

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éez une nouvelle version de son lieu parent
    // cela n’inclut pas l’ID de son enfant.
    const nextParent = {
      ...parent,
      childIds: parent.childIds
        .filter(id => id !== childId)
    };
    // Actulisez l’état de l’objet d’origine...
    setPlan({
      ...plan,
      // ...pour qu’il ait le parent actualisé
      [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);
      }}>
        Compléter
      </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 beaucoup de problèmes. Cela facilite la mise à jour de l’état, et permet de s’assurer qu’il n’y a pas de duplication dans les différentes parties d’un objet imbriqué.

En détail

Améliorer l’utilisation de la mémoire

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

import { useImmer } from 'use-immer';
import { initialTravelPlan } from './places.js';

export default function TravelPlan() {
  const [plan, updatePlan] = useImmer(initialTravelPlan);

  function handleComplete(parentId, childId) {
    updatePlan(draft => {
      // Enlevez des parents l’ID des endroits enfants
      const parent = draft[parentId];
      parent.childIds = parent.childIds
        .filter(id => id !== childId);

      // Oubliez cet endroit et toute sa descendence. 
      deleteAllChildren(childId);
      function deleteAllChildren(id) {
        const place = draft[id];
        place.childIds.forEach(deleteAllChildren);
        delete draft[id];
      }
    });
  }

  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);
      }}>
        Compléter
      </button>
      {childIds.length > 0 &&
        <ol>
          {childIds.map(childId => (
            <PlaceTree
              key={childId}
              id={childId}
              parentId={id}
              placesById={placesById}
              onComplete={onComplete}
            />
          ))}
        </ol>
      }
    </li>
  );
}

Parfois, vous pouvez aussi réduire l’imbrication des états en déplaçant une partie de l’état imbriqué dans les composants enfants. Cela fonctionne bien pour les é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.
  • Choississez 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 évènements d’UI tels que la sélection, conservez l’ID ou l’index dans l’état au lieu de 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 de 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>
  );
}