Skip to content

Blog


Managing React State on DoorDash’s Item Modal Using the Class Pattern

21 avril 2021

|

Winston Zhao

DoorDash‚Äôs Item Modal, one of the most complex components of our app and web frontends, shows customers information about items they can order. This component includes nested pages, dynamic rendering of input fields, and client-side validation requirements. 

Notre récente migration vers une architecture microservices nous a donné l'occasion de repenser la façon dont nous gérons la modale Item sur notre frontend web basé sur React. Travaillant sous la contrainte de ne pas vouloir ajouter un paquetage tiers, nous avons adopté ce que nous appelons le modèle de classe, une torsion sur le modèle de gestion d'état React vanille, prêt à l'emploi.

La réingénierie de notre modale d'élément à l'aide du modèle de classe a permis d'accroître la fiabilité, d'étendre la testabilité et de cartographier notre modèle mental de ce composant. 

Trouver le meilleur modèle de gestion d'état React à adopter 

La première chose que fait tout ingénieur React après avoir tapé npx create-react-app my-app is almost always googling something like ‚Äúhow to manage react state‚Äù, followed by ‚Äúbest react state management libraries‚Äù. 

Ces recherches Google remplissent l'écran d'un grand nombre d'articles différents sur d'innombrables frameworks de gestion d'état React, tels que React Redux, Le reculet MobX, that it‚Äôs difficult to decide which one to buy into. The rapidly-changing landscape can mean that choosing one ${insert hype state management framework} aujourd'hui nécessitera une mise à niveau vers un autre ${insert even more hype state management framework} demain. 

Début 2019, nous avons reconstruit notre application web, en basant la pile technologique sur TypeScript, Apollo/GraphQL et React. Avec de nombreuses équipes différentes travaillant sur les mêmes pages, chaque page avait sa propre façon unique de gérer l'état. 

La complexité de la construction de l'élément modal nous a obligés à repenser la façon dont nous gérons l'état de nos composants complexes. Nous avons utilisé le modèle de classe pour organiser l'état dans la fenêtre modale de l'élément de manière à ce que la logique métier et la logique d'affichage soient facilement différenciées. La reconstruction de la fenêtre modale de l'élément sur la base du modèle de classe a permis non seulement d'accroître sa disponibilité pour les clients, mais aussi de servir de modèle pour les autres états de notre application.

Figure 1 : La fenêtre modale des articles est un formulaire dynamique dans lequel nous affichons des données sur les articles pour nos utilisateurs, prenons et validons les entrées des utilisateurs sur la base des règles de délimitation définies par les données sur les articles, calculons dynamiquement les prix des articles sur la base de ces entrées des utilisateurs et soumettons les articles valides modifiés par les utilisateurs à un magasin de données persistant.

Présentation de notre modèle de gestion d'état React : Le modèle de classe 

Avant de discuter de la façon dont nous avons utilisé un pattern de gestion d'état react, expliquons ce qu'est le pattern de classe et comment nous avons été amenés à l'utiliser. Lorsque nous avons été chargés de reconstruire la modale Item, la structure de données renvoyée par le backend était une structure arborescente JSON imbriquée. Nous avons rapidement réalisé que les données pouvaient également être représentées sous la forme d'un arbre N-aire avec trois types de nœuds distincts : 

  • ItemNode
  • OptionListNode
  • OptionNode. 

Avec le modèle de classe, nous avons rendu le rendu et l'état très faciles à comprendre et nous avons permis à la logique commerciale de vivre directement sur les nœuds de la modale d'élément. 

At DoorDash, we haven‚Äôt standardized on any state management framework, and most commonly use useState/useReducer combinée à l'extraction de données GraphQL. Pour l'itération précédente de notre Item Modal, nous avons utilisé deux useReducers : ItemReducer to manage GraphQL‚Äôs data fetching  and ItemControllerReducer to manage the Item Modal‚Äôs state from UI interactions. 

La nature dynamique du modèle des éléments nécessite de nombreux types de fonctions différentes appelées avec chaque action du réducteur. Par exemple, la création d'une instance du modèle d'élément pour un client envoie une action d'initiation pour gérer la réponse aux données dans ItemReducer. Ensuite, l'Item Modal lance une action d'initiation avec la fonction ItemReducer‚Äôs de l'État à ItemControllerReduceroù nous préparons l'état et effectuons une validation récursive.

It was easy to write integration tests by running our reducer and dispatching actions, then checking the end result. For example, we could dispatch a building Item Modal action with mock data and check to see if the state on ItemReducer and ItemControllerReducer was correct. However, Item Modal‚Äôs smaller moving parts and business logic were more difficult to test. 

Nous voulions rendre l'exécution des tests unitaires sur les nouvelles fonctionnalités d'Item Modal plus rapide et plus facile. De plus, en rendant toutes nos fonctionnalités actuelles testables à l'unité, nous pouvions facilement tester chaque nouvelle fonctionnalité et éviter les régressions. 

La création du modèle de classe a simplifié à l'extrême les tests de la logique commerciale, ne nécessitant aucun paquetage tiers et n'obligeant pas à maintenir notre flux de données unidirectionnel avec useReducer. 

To introduce this pattern, we‚Äôve built a simple to-do list example: 

En commençant par un simple exemple de liste de tâches

L'extraction de la logique métier du réducteur utilisé dans useReducer vers une classe ES6, TodoState, facilite la création d'un test unitaire sans configuration. 

Mise en œuvre de TodoState

In TodoState, we utilize TypeScript’s private and public functions and variables to set a clear delineation of what is internal to TodoState and what should be externally exposed. Private functions should only be called by handleTodoAction, which is described in the paragraph below, or by other internal functions. Public functions consist of handTodoAction and any selector functions that expose the state to any consumers of TodoState.

handleTodoAction devrait sembler très familier à un exemple de réducteur accepter une action et déterminer les fonctions internes à appeler. Dans handleTodoAction, une TodoAction correspond à un cas dans l'instruction switch et déclenche un appel à l'une des méthodes privées de TodoState. Par exemple, setTodoDone ou addTodo modifient l'état, mais ne peuvent être appelées que par handleTodoAction.

public handleTodoAction = (todoAction: TodoAction) => {
   switch (todoAction.type) {
     case TodoActionType.ADD_TODO: {
       this.addTodos(todoAction.todo);
       return;
     }
     case TodoActionType.SET_TODO_DONE: {
       this.setTodoDone(todoAction.todoIndex, todoAction.done);
       return;
     }
   }
 };

todos est stocké dans TodoState en tant que variable privée qui peut être récupérée à l'aide de la méthode publique getTodos. getTodos est l'autre méthode publique et agit de la même manière qu'une méthode sélecteur d'autres frameworks de gestion d'état, tels que Redux.

 public getTodos = () => {
   return this.todos;
 };

Depuis getTodos est une méthode publique, elle peut appeler n'importe quelle méthode privée mais ce serait un anti-modèle car les autres méthodes publiques autres que handleTodoAction ne doit sélectionner que l'État.

Construire un crochet useTodo useReducer personnalisé

Nous créons un useTodo qui englobe le useReducer en n'exposant que ce que le consommateur de la useTodo les besoins en crochets : les todos et les actions addTodo et setTodoDone.

export const useTodo = () => {
 const [todoState, dispatch] = useReducer(TodoReducer, new TodoState());
 const addTodo = (todo: Todo) => {
   dispatch({ type: TodoActionType.ADD_TODO, todo });
 };
 
 const setTodoDone = (todoIndex: number, done: boolean) => {
   dispatch({ type: TodoActionType.SET_TODO_DONE, todoIndex, done });
 };
 const todos = todoState.getTodos();
 return { todos, addTodo, setTodoDone };
};

Cliquez ici pour voir l'intégralité de l'exemple de code dans CodeSandbox.

Nous pouvons ensuite effectuer une copie superficielle en utilisant Object.assign({}, todoState) to prevent side effects on the previous state and preserve typing, then offload the typical reducer logic to the TodoState’s handleTodoAction functionet enfin renvoyer le newTodoState.

const TodoReducer: Reducer<TodoState, TodoAction> = (todoState, todoAction) => {
 const newTodoState = Object.assign({}, todoState);
 newTodoState.handleTodoAction(todoAction);
 return newTodoState;
};

Mise en œuvre des tests unitaires

Comme nous l'avons mentionné plus haut, nous avons conçu le modèle de classe pour faciliter les tests de logique commerciale, ce que nous pouvons démontrer avec le modèle suivant TodoState. We‚Äôre able to test every line of TodoState très facilement, sans aucune configuration préalable. (Bien que nous utilisions l'effet de levier CodeSandbox‚Äôs Jest setup.)

Nous testons la logique commerciale et vérifions les effets secondaires à l'aide de handleTodoAction et les méthodes de sélection publiques (getTodos dans le cas présent), de la même manière qu'un consommateur interagirait en fin de compte avec l'application TodoState. We don‚Äôt even need React for these tests because TodoState is purely decoupled and written in JavaScript. This means we don‚Äôt have to fumble with looking up how to render hooks in tests or find out that a third party package needs to be upgraded to support writing unit tests. 

it("addTodo - should add todo with text: Snoopy", () => {
   const todoState = new TodoState();
   todoState.handleTodoAction({
     type: TodoActionType.ADD_TODO,
     todo: {
       text: "Snoopy",
       done: false
     }
   });
   const todos = todoState.getTodos();
   expect(todos.length).toBe(1);
   expect(todos[0].text).toBe("Snoopy");
 });

Cliquez ici pour voir les tests unitaires dans CodeSandbox.

Mettre useTodo et TodoState ensemble dans l'interface utilisateur

L'exemple de code ci-dessous est une interface utilisateur très simple qui montre à quel point les interactions avec l'état deviennent simples lorsque l'on utilise le modèle de classe.

Les TodoList appelle le crochet useTodo pour obtenir les todos à côté de l'élément addTodo et setTodoDone fonctions. 

const { todos, addTodo, setTodoDone } = useTodo();

Cliquez ici pour voir l'exemple de code dans CodeSandbox.

Le code que nous renvoyons est très simple, puisqu'il suffit de faire correspondre le code todos de useTodo. Grâce au modèle de classe, nous pouvons conserver un balisage très simple, même dans l'exemple modal plus complexe de la section suivante.

{todos.map((todo: Todo, index: number) => {
         return (
           <div
             key={index}
             style={{
               display: "flex",
               alignItems: "center"
             }}
           >
             {todo.text}
             <input
               type="checkbox"
               onChange={toggleTodoDone(index, todo)}
               checked={todo.done}
             />
           </div>
         );
       })}

Cliquez ici pour voir l'exemple de code dans CodeSandbox.

Ensuite, nous créons des gestionnaires d'événements. Nous utiliserons handleAddTodo et toggleTodoDone pour les actions de clic de bouton et de changement de case à cocher.

const handleAddTodo = () => {
   addTodo({
     text: todoInputText,
     done: false
   });
   resetTodoInputText();
 };
 
 const toggleTodoDone = (todoIndex: number, todo: Todo) => () => {
   setTodoDone(todoIndex, !todo.done);
 };

En TodoListnous joignons handleAddTodo à la onClick du bouton. Lorsque l'on clique sur le bouton, plusieurs choses se produisent pour rendre la nouvelle todo sur TodoListcomme le montre la figure 2 ci-dessous.

<button onClick={handleAddTodo}>Add Todo</button>

Cliquez ici pour voir l'exemple de code dans CodeSandbox.

Figure 2 : Le modèle de classe suit toujours le flux de données unidirectionnel de useReducer et d'autres cadres de gestion d'état comme Redux.
  1. TodoList - Le clic d'un bouton déclenche l'alarme handleAddTodo 
  2. handleAddTodo - Nous utilisons la valeur actuelle de todoInputText pour créer une charge utile de données sur les tâches. Ensuite, addTodo (exposée par le biais de la useTodo ) est appelé avec ces données.
  3. addTodo - envoie un AddTodo TodoAction à la TodoReducer avec le todo data payload.
  4. TodoReducer - makes a new copy of the current state and calls TodoState’s handleTodoAction avec le TodoAction.
  5. handleTodoAction - détermine que le TodoAction est un AddTodo et appelle la fonction privée addTodo pour ajouter les données des todos aux todos et aux retours.
  6. TodoReducer - la nouvelle copie de l'état actuel comprend maintenant aussi la mise à jour todos et renvoie le nouvel état

A l'intérieur de la useTodo hook, we use TodoState‚Äôs getTodos pour sélectionner les todos mis à jour sur TodoState et le renvoie au client.

Le client détecte le changement d'état et effectue un nouveau rendu pour afficher le nouvel état. todos sur TodoState

Comment utiliser le modèle de classe dans la fenêtre modale de l'élément

As mentioned above, there are a lot of moving parts in the Item Modal. With our rebuild, we‚Äôve consolidated the two reducers into one, TreeReducerIl s'agit d'un système de gestion des données qui gère la recherche et la consolidation des données (demande initiale d'article et demandes d'options imbriquées) et conserve l'état de l'article, tel que la quantité, le prix courant et la validité de l'article. 

La consolidation en un seul reducer rend les tests d'intégration plus importants plus simples et nous permet d'avoir toutes les actions en un seul endroit. Nous utilisons le modèle de classe ainsi que le modèle TreeReducer pour construire un TreeState, similaire à la TodoState nous avons dépassé les limites. 

Notre TreeState expose un handleTreeAction fonction publique qui gère toutes les actions entrantes et déclenche une série d'appels de fonctions.

Description de TreeState

La caractéristique la plus importante de la reconstruction modale d'un élément est qu'il s'agit d'un TreeState, représenté par un arbre N-aire, comme le montre la figure 3 ci-dessous :

Figure 3 : Le TreeState utilisé dans notre Item Modal est représenté sous la forme d'un arbre N-aire. Dans cette utilisation, chaque élément a un ItemNode, et cet ItemNode peut avoir un nombre quelconque d'OptionListNode. Chaque OptionListNode peut avoir un nombre quelconque d'OptionNodes, et ces OptionNodes peuvent avoir un nombre quelconque d'OptionListNodes, et ainsi de suite.

The recursive nature of the Item Modal and its nodes is similar to a Reddit post‚Äôs comment section. A comment can have child comments, which have child comments, and so on. But for the Item Modal, each node has a different responsibility. 

Mise en œuvre de ItemNode

Un ItemNode holds the item’s information, including name, ID, description, price, and imageUrl. An ItemNode is always the TreeState’s root and its children are always OptionListNodes.

export class ItemNode {
 private imageUrl?: string;
 private name: string;
 private id: string;
 private isValid: boolean;
 private children: OptionListNode[];
 ...
}

Cliquez ici pour voir le code exemple dans CodeSandbox.

Pour l'ensemble des ItemNode‚Äôs nous pouvons facilement écrire des tests pour l'ensemble de leur logique commerciale en ajoutant simplement OptionListNode‚Äôs en tant qu'enfants et de tester la validation.

it("should be valid if all of its OptionListNodes are valid", () => {
     const optionListNode = new OptionListNode({
       id: "test-option-list",
       name: "Condiments",
       minNumOptions: 0,
       maxNumOptions: 9,
       selectionNode: SelectionNodeType.SINGLE_SELECT,
       parent: itemNode
     });
     optionListNode.validate();
     expect(itemNode.getIsValid()).toBe(true);
   });

Cliquez ici pour voir le reste des tests unitaires dans CodeSandbox.

Mise en œuvre d'OptionListNode

Un OptionListNode garde la trace de toutes les conditions limites de validation et détermine le type d'options à afficher dans la fenêtre modale de l'élément. Par exemple, si un utilisateur a sélectionné une pizza sur l'application web, OptionListNode peut lancer une liste d'options multi-sélectionnées exigeant la sélection d'un minimum de deux et d'un maximum de quatre options de nappage. Un OptionListNode‚Äôs les enfants sont OptionNodeset son nœud parent peut être soit un ItemNode (cas normal) ou un OptionNode (cas des options imbriquées).

export class OptionListNode {
 private id: string;
 private name: string;
 private minNumOptions: number;
 private maxNumOptions: number;
 private selectionNode: SelectionNodeType;
 private parent?: ItemNode | OptionNode;
 private children: OptionNode[];
 private isValid: boolean;
...
}

Cliquez ici pour voir le code exemple dans CodeSandbox.

Les OptionListNode handles most of the critical business logic in determining client side error states to ensure a user is submitting a valid item in the correct format. It’s validate method is more complicated than OptionNode et ItemNode and we need to check if the node satisfies the boundary rules. If the user does not follow the merchant’s item boundary configuration rules the OptionListNode sera invalide et l'interface utilisateur affichera un message d'erreur.

public validate = () => {
   const validSelectedOptions = this.children.filter(
     (optionNode) => optionNode.getIsSelected() && optionNode.getIsValid()
   ).length;
   this.isValid =
     this.satisfiesMinNumOptions(validSelectedOptions) &&
     this.satisfiesMaxNumOptions(validSelectedOptions);
 
   this.parent?.validate();
 };

Nous pouvons facilement tester cette logique de validation pour un OptionListNode qui nécessite un minNumOptions >= 1 en ajoutant des OptionNodes puis en appelant select. 

describe("OptionListNode", () => {
  let optionListNode: OptionListNode = new OptionListNode({
   id: "test-option-list",
   name: "Condiments",
   minNumOptions: 1,
   maxNumOptions: 9,
   selectionNode: SelectionNodeType.SINGLE_SELECT
 });
  beforeEach(() => {
   optionListNode = new OptionListNode({
     id: "test-option-list",
     name: "Condiments",
     minNumOptions: 1,
     maxNumOptions: 9,
     selectionNode: SelectionNodeType.SINGLE_SELECT
   });
 });
 
  describe("validate", () => {
   it("should not be valid with no OptionNode children", () => {
     optionListNode.validate();
     expect(optionListNode.getIsValid()).toBe(false);
   });
   it("should be valid if one OptionNode is selected and is valid", () => {
     const optionNode = new OptionNode({
       parent: optionListNode,
       id: "test",
       name: "ketchup"
     });
     optionNode.select();
     optionListNode.validate();
     expect(optionListNode.getIsValid()).toBe(true);
   });
   it("should not be valid if no OptionNodes are selected", () => {
     const optionNode = new OptionNode({
       parent: optionListNode,
       id: "test",
       name: "ketchup"
     });
     optionListNode.validate();
     expect(optionListNode.getIsValid()).toBe(false);
   });
 });
}

Cliquez ici pour voir le reste des tests unitaires dans CodeSandbox.

Mise en œuvre d'OptionNode

Un OptionNode conserve la trace de son état de sélection, de son prix, de son nom et, éventuellement, d'un nextCursor. Un OptionNode avec un nextCursor indique qu'il y a des options imbriquées.

export class OptionNode {
 private id: string;
 private name: string;
 private nextCursor?: string;
 private children: OptionListNode[];
 private isSelected: boolean;
 private isValid: boolean;
 private parent?: OptionListNode;
…
}

Cliquez ici pour voir le code exemple dans CodeSandbox.

Plutôt que de construire un arbre entier, nous pouvons isoler le test au niveau de l'élément OptionNode et ses parents et enfants immédiats. 

Nous pouvons tester des comportements assez compliqués, par exemple lorsque nous sélectionnons un OptionNodeil désélectionnera tous ses frères et sœurs. OptionNodes s'il s'agit d'un SINGLE_SELECT.

describe("OptionNode", () => {
 let optionNode = new OptionNode({
   name: "Test",
   id: "test-option"
 });
 beforeEach(() => {
   optionNode = new OptionNode({
     name: "Test",
     id: "test-option"
   });
 });
  describe("select", () => {
    it("if its parent is a SINGLE_SELECTION option list all of its sibling options will be unselected when it is selected", () => {
     const optionListNode = new OptionListNode({
       id: "test-option-list",
       name: "Condiments",
       minNumOptions: 1,
       maxNumOptions: 9,
       selectionNode: SelectionNodeType.SINGLE_SELECT
     });
     const siblingOptionNode = new OptionNode({
       id: "sibling",
       name: "Ketchup",
       parent: optionListNode
     });
     const testOptionNode = new OptionNode({
       id: "Test",
       name: "Real Ketchup",
       parent: optionListNode
     });
     expect(siblingOptionNode.getIsSelected()).toBe(false);
     expect(testOptionNode.getIsSelected()).toBe(false);
     siblingOptionNode.select();
     expect(siblingOptionNode.getIsSelected()).toBe(true);
     expect(testOptionNode.getIsSelected()).toBe(false);
     testOptionNode.select();
     // should unselect the sibling option node because its parent is a single select
     expect(siblingOptionNode.getIsSelected()).toBe(false);
     expect(testOptionNode.getIsSelected()).toBe(true);
   });
 });
 });

Cliquez ici pour voir le code exemple dans CodeSandbox.

Cliquez ici pour voir le reste des tests unitaires dans CodeSandbox.

État de l'arbre

TreeState garde la trace de la racine de l'arbre N-aire (un ItemNode), un magasin de clés et de valeurs qui permet aux Accès O(1) à n'importe quel nœud de l'arbre, ainsi qu'au nœud actuel à des fins de pagination. 

TreeState’s handleTreeAction interagit directement avec le ItemNodes, OptionListNodeset OptionNodes

Visualisation du TreeState avec un exemple de commande de burrito

To better visualize these different nodes, let’s take a look at a burrito order in which a user can choose from two meats, chicken or steak, and two beans, pinto or black. We can take it further by allowing the user to select the quantity of meat via a nested option.

Figure 4 : La commande d'un burrito offre plusieurs options, comme le montre l'arbre N-aire ci-dessus, ce qui en fait un moyen utile de visualiser un élément autrement complexe.
  • Le burrito est un ItemNode et est également la racine de TreeState. Il a deux enfants OptionListNodesViande et burrito. 
    • Les haricots sont un OptionListNode avec deux enfants OptionNodesPinto et Black.
      • Pinto est un OptionNode.
      • Le noir est un OptionNode.
  • La viande est un OptionListNode avec deux enfants OptionNodesPoulet et bifteck. 
    • Le poulet est un OptionNode
  • Le steak est un OptionNode avec un enfant OptionListNode, Quantity, ce qui signifie qu'il s'agit d'une option imbriquée. 
    • La quantité est un OptionListNode avec deux enfants OptionNodes, ¬Ω and 2x.
      • ¬Ω is an OptionNode
      • 2x est un OptionNode

Mise en œuvre du modèle de classe sur l'élément modal

Nous avons construit un autre exemple d'Item Modal sur CodeSandbox pour démontrer comment nous utilisons TreeState dans l'exemple de commande de burritos décrit ci-dessus. Dans ce guide, nous nous concentrons sur un exemple simple pour montrer le modèle de classe. Cependant, nous incluons également les options imbriquées plus compliquées pour ceux qui souhaitent aller plus loin.

Cliquez ici pour voir le code exemple dans CodeSandbox.

Nous exposons un useTree de la même manière que nous avons mis en œuvre le useTodo dans notre TodoList exemple ci-dessus. useTree interagit avec TreeStateen exposant des sélecteurs pour currentNode et des fonctions de mutation pour sélectionner et désélectionner les options, et en construisant l'arbre initial.

Cliquez ici pour voir le code exemple dans CodeSandbox.

Construction de l'arbre initial

La première partie critique du rendu de l'élément modal est la construction de l'élément initial TreeState avec les données de l'article, comme le montre la figure 5 ci-dessous.

Figure 5 : Dans le flux de notre modèle de classe, comme le montre notre exemple d'élément modal, la première étape du cycle initialise l'useTree, suivie d'un appel à l'API pour récupérer les données, de la construction de l'arbre, de la validation et d'une étape finale au cours de laquelle l'arbre est réaffiché.
  1. Item Modal - Nous initialisons le hook useTree lors du rendu initial, en exposant buildTree, currentNode, selectOption, unselectOption, setCurrentNode (qui n'est pas abordé dans ce guide), et addTreeNodes (qui n'est pas non plus abordée dans ce guide). Lorsque nous initialisons le useTree le crochet TreeState est dans son état par défaut, avec currentNode indéfini, racine indéfinie, et nodeMap fixé à {}
  2. Poste Modal - A useEffect se déclenchera et détectera que currentNode est indéfini et récupère les données de l'élément et appelle buildTree exposée à partir de useTree avec les données de l'élément. Cependant, dans cet exemple de modèle de classe, nous allons omettre la mise en œuvre de l'appel API et utiliser des données fictives (trouvées dans la rubrique TreeState/mockData). 
  3. Item API Response est reçu - buildTree envoie un BUILD_TREE qui doit être traité par treeReducer
  4. treeReducer fait une copie profonde de l'arbre actuel. TreeState et appelle ensuite TreeState.handleTreeAction
const TreeReducer: Reducer<TreeState, TreeAction> = (treeState, treeAction) => {
 const newTreeState = cloneDeep(treeState);
 newTreeState.handleTreeAction(treeAction);
 return newTreeState;
};

Cliquez ici pour voir le code exemple dans CodeSandbox.

  1. TreeState.handleTreeAction commence à ressembler à un réducteur Redux typique avec ses déclarations switch. Dans l'instruction switch, le type d'action entrant correspond à TreeActionType.BUILD_TREE. Ici, TreeState crée tous les nœuds, ItemNode, OptionListNodeet OptionNodeà partir des données utiles de l'arbre initial. Nous créons ItemNode en createTreeRoot, OptionListNode en createOptionListNodeset OptionNode en createOptionNodes

Cliquez ici pour voir le code exemple dans CodeSandbox.

The critical piece here is that the nodes are created with the correct pointers to their children and parents. The Burrito ItemNode’s children are Meat and Beans, which are in turn OptionListNodes with Burrito as their parent. Meat’s children are Chicken and Steak, which are also OptionNodes with Meat as their parent. Beans’ children are Pinto and Black with Beans as their parent.

  1. TreeReducer - La première TreeState est maintenant mis à jour et construit, ce qui déclenche un nouveau rendu dans le composant de l'application web qui rend le currentNode, comme le montre la figure 6 ci-dessous :
Figure 6 : Le DOM rend l'élément modal après la mise à jour et la construction de l'état initial de l'arbre.

Une fois que nous avons demandé la charge utile des données de l'élément, il y a beaucoup de pièces mobiles impliquées dans la construction de l'élément initial de l'élément. TreeStateet de nombreux endroits où les choses peuvent mal tourner. Le modèle de classe nous a permis d'écrire facilement des tests pour différents types d'éléments et de vérifier que le modèle de classe est correct. TreeState a été construit correctement. 

Dans l'exemple ci-dessous, nous écrivons une suite de tests pour notre cas d'utilisation de commande de burrito afin de nous assurer que toutes les relations et la validité initiale sont correctes entre les éléments suivants ItemNode, OptionListNodeset OptionNodes. We think of these as ‚Äúintegration‚Äù tests as they test the entire side effects of a reducer action as opposed the ‚Äúunit‚Äù tests that we wrote to test the business logic of  ItemNode, OptionListNodeet OptionNode. En production, nous disposons de notre suite de tests unitaires pour tous les types de données utiles des éléments, tels que la réorganisation, les options par défaut et les options imbriquées. 

const treeState = new TreeState();
   treeState.handleTreeAction({
     type: TreeActionType.BUILD_TREE,
     itemNodeData,
     optionListNodeDataList: optionListData,
     optionNodeDataList: optionData
   });
   it('Burrito should be the parent of Beans and Meat and Beans and Meat should be Burrito"s children', () => {
     const burritoNode = treeState.getRoot();
     burritoNode?.getChildren().forEach((optionListNode) => {
       expect(
         optionListNode.getName() === "Meat" ||
           optionListNode.getName() === "Beans"
       ).toBeTruthy();
       expect(optionListNode.getParent()).toBe(burritoNode);
     });
   });

Cliquez ici pour voir le code exemple dans CodeSandbox.

Nous avons des tests pour toute la logique commerciale à chaque niveau de la modalité Item, couvrant chaque niveau de nœud et le TreeState dans le CodeSandbox ci-dessous. Après avoir construit un arbre, nous avons des tests qui s'assurent que chaque nœud est initialisé correctement.  

Cliquez ici pour voir le code exemple dans CodeSandbox.

Comment l'élément modal interagit avec l'état de l'arbre (TreeState)

The TreeState is critical to every aspect of the Item Modal. It is involved in the Item’s Modal’s rendering, client-side validation, and data fetching. Every user interaction results in a change to the TreeState.

Rendu

Avec le modèle de classe et TreeState, le rendu de la modalité Item est devenu extrêmement simple, car nous avons une relation quasi univoque entre le balisage et les formes d'état. ItemNode rend un composant Item, OptionListNode rend un composant OptionList, et OptionNode rend un composant Option. 

The Item Modal can be in two different states: the initial item page or on a nested option page. We won’t cover the nested option case here but we determine what type of page to render by using Type Guards.

Pour la page initiale de l'article, nous rendons un composant ItemBody, qui accepte l'élément currentNode, selectOptionet unselectOption en tant que propriétés. ItemBody rend le nom de l'élément, met en correspondance ses enfants et rend OptionListset affiche un bouton de soumission qui ne peut être utilisé que si toutes les options répondent aux critères de validation appropriés. 

 {itemNode.getChildren().map((optionListNode) => (
       <OptionList
         key={optionListNode.getId()}
         optionListNode={optionListNode}
         selectOption={selectOption}
         unselectOption={unselectOption}
         setCurrentNode={setCurrentNode}
       />
     ))}

Inside ItemBody, the markup is really simple because we just render the ItemNode’s children which are OptionListNodes as OptionList components, as shown in this CodeSandbox code sample.

Les OptionList Le composant accepte optionListNode, selectOption, unselectOptionet setCurrentNode les propriétés. A la suite de ces apports, OptionList rend son nom, détermine si le OptionListNode est valide, et met en correspondance ses enfants, ce qui rend Options.

{optionListNode.getChildren().map((optionNode) => (
       <Option
         key={optionNode.getId()}
         optionNode={optionNode}
         selectionNode={optionListNode.getSelectionNode()}
         selectOption={selectOption}
         unselectOption={unselectOption}
         setCurrentNode={setCurrentNode}
       />
     ))}

Cliquez ici pour voir le code exemple dans CodeSandbox.

Les Option Le composant accepte optionNode, selectionNode, selectOption, unselectOptionet setCurrentNode propriétés. selectionNode est la partie dynamique du formulaire et détermine si un bouton radio ou une case à cocher est affiché. SelectionNodeType.SINGLE_SELECT rend un bouton radio

Cliquez ici pour voir le code exemple dans CodeSandbox.

SelectionNodeType.MULTI_SELECT rend une case à cocher.

Cliquez ici pour voir le code exemple dans CodeSandbox.

Interaction via selectOption et unselectOption

The whole point of the Item Modal is to save and validate a user’s inputs and modifications to an item. We use selectOption and unselectOption functions exposed from useTree to capture these user inputs and modifications.

Figure 7 : Les fonctions selectOption et unselectOption sont capables de capturer les entrées de l'utilisateur dans notre modale d'élément.

Pour illustrer le cycle de vie de ce qui se passe lorsqu'un Option‚Äôs nous allons voir ce qui se passe lorsqu'un utilisateur clique sur la case à cocher Pinto Beans de notre exemple. Le cycle de vie pour arriver à la case à cocher TreeState.handleTreeAction est exactement la même que la construction de l'arbre initial. 

Figure 8 : L'état de l'arbre est la source de vérité pour la fenêtre modale de l'élément, et chaque interaction de l'utilisateur déclenche une action pour mettre à jour l'état.
  1. Haricots Pinto Clicked -  handleMultiSelectOptionTouch est déclenché sur onChange événement. Le rappel vérifie si l'événement OptionNode est déjà sélectionné. S'il est déjà sélectionné, il appellera alors unselectOption avec son ID. Sinon, il appellera selectOption Dans cet exemple, il appelle selectOption.
  2. selectOption - envoie une action TreeActionType.SELECT_OPTION avec un identifiant d'option. 
const selectOption = (optionId: string) => {
   dispatch({ type: TreeActionType.SELECT_OPTION, optionId });
 };

Cliquez ici pour voir le code exemple dans CodeSandbox.

  1. treeReducer -deep clone l'état actuel de l'arbre et appelle TreeState.handleTreeAction.
  2. handleTreeAction - nous utilisons getNode pour récupérer le nœud dans la base de données nodeMap avec un optionID
case TreeActionType.SELECT_OPTION: {
      const optionNode = this.getNode(treeAction.optionId);
      if (!(optionNode instanceof OptionNode))
         throw new Error("This is not a valid option node");
       optionNode.select();
       if (optionNode.getNextCursor() !== undefined)
         this.currentNode = optionNode;
       return;
     }

Cliquez ici pour voir le code exemple dans CodeSandbox.

  1. OptionNode.select - tourne le isSelected à vrai et appelle OptionNode.validate.
public validate = () => {
   this.isValid = this.children.every((optionListNode) =>
     optionListNode.getIsValid()
   );
   this.parent?.validate();
 };

Cliquez ici pour voir le code exemple dans CodeSandbox.

  1. OptionListNode.validate, we need to validate the user‚Äôs input and determine whether it satisfies the boundary rules set by its minNumOptions et maxNumOptions. Après avoir vérifié les règles de délimitation, le OptionListNode‚Äôs parent validate is called, which is on ItemNode.
public validate = () => {
   const validSelectedOptions = this.children.filter(
     (optionNode) => optionNode.getIsSelected() && optionNode.getIsValid()
   ).length;
   this.isValid =
     this.satisfiesMinNumOptions(validSelectedOptions) &&
     this.satisfiesMaxNumOptions(validSelectedOptions);
 
   this.parent?.validate();
 };

Cliquez ici pour voir le code exemple dans CodeSandbox.

  1. ItemNode.validate - La validation est similaire à un OptionNode‚Äôs la validation. Il vérifie si tous ses enfants sont valides afin de déterminer si l'élément ItemNode is valid, but it doesn‚Äôt call its parent to validate as it is the root of the tree.
public validate = () => {
   this.isValid = this.children.every((optionListNode) =>
     optionListNode.getIsValid()
   );
 };

Cliquez ici pour voir le code exemple dans CodeSandbox.

  1. Burrito - non valide, haricots - valide, haricots pinto - non valide - Notre TreeState est mis à jour avec l'option Haricots pinto sélectionnée, et ses nœuds parent, Haricots, et grand-parent, Burrito, ont été validés. Ce changement d'état déclenche un nouveau rendu et l'option Pinto Beans apparaît sélectionnée, tandis que Beans passe de non valide à valide.

Le fait de cliquer sur les haricots Pinto fonctionne de la même manière que la construction de l'arbre initial. Lorsqu'un utilisateur clique sur une option, nous devons nous assurer que l'élément TreeState est mis à jour et tous nos ItemNodes, OptionListNodeset OptionNodes sont correctement définies comme invalides ou valides. Nous pouvons faire la même chose avec l'action de construction initiale de l'arbre et initialiser un fichier TreeStateLancez l'action de sélection de l'option, puis vérifiez tous les nœuds pour vous assurer que tout est correct. 

Cliquez ici pour voir le code exemple dans CodeSandbox.

Brève explication de la validation

For any user interaction, we need to recursively climb up the tree from where the user interaction initiates, as this interaction can result in that node becoming valid and affecting its parent’s validity, and its parent’s parent’s validity, and so on.

Dans l'exemple des haricots Pinto, nous devons valider à partir du nœud Haricots Pinto. Nous voyons d'abord qu'il n'a pas d'enfant, donc, en tant que noeud feuille, il est immédiatement valide. Ensuite, nous appelons validate sur Beans parce que nous devons vérifier si un nœud isSelected = true Le nœud Pinto Beans peut remplir nos conditions limites Beans. Dans cet exemple, c'est le cas, nous marquons donc Beans comme valide et enfin nous appelons validate sur Burrito. Sur Burrito, nous voyons qu'il a deux nœuds OptionListNodes comme les enfants et Beans est maintenant valide. Cependant, Meat n'est pas valide, ce qui signifie que Burrito n'est pas valide, comme le montre la figure 9 ci-dessous :


Figure 9 : pour chaque nœud, le rose signifie que le nœud n'est pas valide, le vert signifie que le nœud est valide, le rouge signifie que le nœud a été cliqué et sélectionné, et le jaune signifie qu'il est en cours de validation.

Comment utiliser le terrain de jeu des options imbriquées

We haven’t gone through the nested options example in this code walkthrough, but the code is available in the CodeSandbox we created. To unlock the nested options example and play around with the code, please uncomment out here and here.

Conclusion

State management can get exponentially more complicated as an application‚Äôs complexity grows. As we began the migration process to microservices, we re-evaluated how data flows through our application and gave some thought on how to improve it and better serve us in the future. 

Le modèle de classe nous permet de séparer clairement notre couche de données et d'état de notre couche de visualisation et d'écrire facilement une suite robuste de tests unitaires pour garantir la fiabilité. Après la reconstruction, nous avons utilisé le modèle de classe pour construire et déployer la fonctionnalité de réorganisation à la fin de l'année 2020. Nous avons ainsi pu facilement écrire des tests unitaires pour couvrir notre nouveau cas d'utilisation, faisant de l'une de nos fonctionnalités les plus compliquées sur le web l'une des plus fiables. 

Auparavant, il était facile de tester l'ensemble de la sortie d'une action et ses effets secondaires sur l'ensemble de l'état, mais nous n'étions pas en mesure d'obtenir facilement la granularité de toutes les parties mobiles (validation des nœuds individuels, messages d'erreur, etc. Cette granularité du modèle de classe et la nouvelle facilité de test ont renforcé notre confiance dans la fonctionnalité modale de l'élément et nous avons pu construire de nouvelles fonctionnalités par-dessus sans régression.  

It‚Äôs important as a team to find a pattern or library to commit to in order for everyone to be on the same page. For teams coping with a similar situation, where state is getting more complicated and the team is not married to a specific library, we hope this guide can spark some inspiration in building a pattern to manage the application‚Äôs state. 

About the Author

Emplois connexes

Localisation
Seattle, WA; Sunnyvale, CA; San francisco, CA
Département
Ingénierie
Localisation
San Francisco, CA ; Sunnyvale, CA ; Los Angeles, CA ; Seattle, WA ; New York, NY
Département
Ingénierie
Localisation
San Francisco, CA ; Sunnyvale, CA ; Los Angeles, CA ; Seattle, WA ; New York, NY
Département
Ingénierie
Localisation
New York, NY; San Francisco, CA; Los Angeles, CA; Seattle, WA; Sunnyvale, CA
Département
Ingénierie
Localisation
Toronto, ON
Département
Ingénierie