React - Typescript par la pratique - Trello - State and reducer

Introduction

Si vous n'avez pas suivi la partie 1 : React - Trello - Part 1

Précédemment, nous avons créé l’ensemble des composants nécessaires. Maintenant, il nous reste à assembler ces composants afin de les rendre disponibles dans le layout. Nous introduirons aussi la notion de state dans ce chapitre qui vous permettra de manipuler des données.

Un autre point important sera l’utilisation d’un reducer qui permettra de faire des changements sur le state, mais aussi d’exécuter des actions qui altèreront ce state.

State et reducers

Notre composant est maintenant fini, mais nous avons besoin d’ajouter notre composant AddNewItem dans le layout de notre application. Dans un premier temps, l’objectif sera d’afficher des logs pour s’assurer que tout fonctionne comme nous le souhaitons. Ouvrons notre App.tsx et importons notre composant :

 import React from "react";
import "./styles/index.scss";
import { AppContainer } from "./styled-components/container";
import { Card } from "./components/card";
import { Column } from "./components/column";
import { AddNewItem } from "./components/add-new-item";

Maintenant nous pouvons ajouter le composant :

     <AppContainer>
      <Column text="To Do">
        <Card text="Generate app scaffold" />
      </Column>
      <Column text="In Progress">
        <Card text="Learn Typescript" />
      </Column>
      <Column text="Done">
        <Card text="Begin to use static typing" />
      </Column>
      <AddNewItem toggleButtonText="+ Add another list" onAdd={console.log} />
    </AppContainer>

Comme dit précédemment, chaque clic ajoutera un log dans la console afin de s'assurer du bon fonctionnement. Maintenant, notre bouton est disponible dans notre layout :

Il ne nous reste plus qu'à ajouter ce bouton dans nos colonnes, pour cela nous devons nous rendre dans notre composant Column :

 import React from "react";
import { ColumnContainer } from "../styled-components/container";
import { ColumnTitle } from "../styled-components/title";
import { AddNewItem } from "./add-new-item";

interface ColumnProps {
  text?: string;
}

export const Column = ({
  text,
  children,
}: React.PropsWithChildren<ColumnProps>) => {
  return (
    <ColumnContainer>
      <ColumnTitle>{text}</ColumnTitle>
      {children}
      <AddNewItem
        toggleButtonText="+ Add another task"
        onAdd={console.log}
        dark
      />
    </ColumnContainer>
  );
};

BIEN ! Maintenant nous pouvons vérifier que tout va bien :

OK ici ça fonctionne, mais il existe un potentiel problème : Quand nous cliquons sur 'Add another task/list', l'utilisateur tape sur le clavier et rien de s'affiche. En effet, il nous manque l'autofocus au moment du clique. Pour cela React nous propose une solution simple :

  • Refs: fournit un moyen d'accéder aux nœuds du DOM (uniquement les éléments React)
  • useRef: Hook permettant d'accéder à l'élément ref ciblé.

Nous allons mettre tout cela en application en ajoutant un dossier utils. Très souvent dans vos applications, il est recommandé d'utiliser ce genre de fichiers pour éviter la redondance ! Ici créons ce dossier à la base du dossier src et ajoutons le fichier use-focus.ts :

 import { useRef, useEffect } from "react"
export const useFocus = () => {
    const ref = useRef<HTMLInputElement>(null)
    useEffect(() => {
        ref.current?.focus()
    }, [])
    return ref
}

Quelques explications :

  • useRef: permet de donner accès à l'élément input
  • HTMLInputElement: réfère le type d'élément que ref a besoin de fournir à Typescript.
  • ref.current?: permet de rendre le current optionnel et éviter une erreur future
  • useEffect: Hook d'effet qui nous permettra d'appliquer le focus à notre input

Maintenant, importons cet util dans notre classe NewItemForm :

 import React, { useState } from "react";
import { NewItemButton } from "../styled-components/button";
import { NewItemFormContainer } from "../styled-components/container";
import { NewItemInput } from "../styled-components/form";
import { useFocus } from "../utils/use-focus";

Bien, comme nous l'avons expliqué précédemment, refs permet l'accès à un nœud React du DOM. Pour pointer sur ce nœud, nous avons besoin de cibler celui-ci avec l'élément: ref. Cette directive est disponible directement sur l'élément pointé :

 export const NewItemForm = ({ onAdd }: NewItemFormProps) => {
  const [text, setText] = useState("");
  const inputRef = useFocus();
  return (
    <NewItemFormContainer>
      <NewItemInput
        ref={inputRef}
        value={text}
        onChange={(e) => setText(e.target.value)}
      />
      <NewItemButton onClick={() => onAdd(text)}>Create</NewItemButton>
    </NewItemFormContainer>
  );
};

Maintenant si vous testez l'application, vous devriez voir l'autofocus s'activer au moment du clique :

State, Redux and drag and drop :

Dans cette sous-partie nous allons ajouter le Global state qui nous permettra d'ajouter par la même occasion la logique métier. L'idée étant de gérer ce state en utilisant useReducer. Détaillons ce qu'est useReducer: c'est un hook que React nous fournit afin de gérer des objets complexes (avec plusieurs champs). L'idée principale est qu'au lieu de créer une mutation sur l'objet original, on crée une nouvelle instance avec les valeurs désirées :

Reducer va donc calculer un nouvel état en combinant l'ancien état avec un nouvel objet. On a donc 3 choses à retenir :

  1. Le state originel qui sera détruit pour être recréé
  2. Une action qui permettra de créer la fonction de calcul. Il doit contenir la ou les nouvelles valeurs pour le ou les champs à changer.
  3. Le nouveau state qui sera ensuite créé.

Pour appeler la fonction reducer nous avons besoin d'appeler la fonction comme suit :

 const [state, dispatch] = useReducer(reducer, initialState)

La fonction de dispatch permettra d'envoyer les actions au reducer.

Bon après cette théorie, créons notre state. Je vous propose de créer un dossier states (nous souhaitons partager l'information), et d'y ajouter le fichier app-state-context.tsx. Avant de commencer, nous allons définir le typage de nos objets :

Nous souhaitons exécuter/supprimer/modifier des tâches, ces tâches sont définies par un texte, mais afin de les différencier, nous allons ajouter un ID.

 interface Task {
    id: string
    text: string
}

Ces tâches sont regroupées dans un bloc comme "Todo", "In progress",... qui eux aussi possèdent un nom, une liste de tâches mais aussi un ID :

 interface Column {
    id: string
    text: string
    tasks: Task[]
}

Enfin, notre state représente un liste de "colonne" :

 export interface AppState {
  lists: Column[];
}

Parfait, maintenant définissons notre state, avec des données mockées :

 export const appData: AppState = {
  lists: [
    {
      id: "0",
      text: "To Do",
      tasks: [{ id: "c0", text: "Generate app scaffold" }],
    },
    {
      id: "1",
      text: "In Progress",
      tasks: [{ id: "c2", text: "Learn Typescript" }],
    },
    {
      id: "2",
      text: "Done",
      tasks: [{ id: "c3", text: "Begin to use static typing" }],
    },
  ],
};

Nous devons maintenant créer le contexte. Nous devons définir le type de ce context :

 interface AppStateContextProps {
  state: AppState;
}

Pour l'instant, nous voulons juste rendre appState disponible à travers le contexte, c'est pourquoi c'est le seul champ de notre type. Par défaut, lors de la création de notre contexte, React demande un objet. Cet objet est utilisé uniquement si nous ne voulons pas intégrer notre application dans notre AppStateProvider (que nous définirons par la suite). Nous pouvons donc oublier cela et passer un objet vide lors de la création de notre contexte :

 import React, { createContext } from "react";

export const AppStateContext = createContext<AppStateContextProps>({} as AppStateContextProps)

Définissons notre AppStateProvider dans un nouveau fichier de notre dossier states (app-state-provider.tsx):

 import { AppStateContext } from "./app-state-context";
import { appData } from "./app-state-context";

export const AppStateProvider = ({ children }: React.PropsWithChildren<{}>) => {
  return (
    <AppStateContext.Provider value={{ state: appData }}>
      {children}
    </AppStateContext.Provider>
  );
};

Pour rendre notre state disponible, et pour le "dispatch" à travers nos différents composants, nous devons l'importer dans notre index.tsx :

 import { AppStateProvider } from "./states/app-state-provider";

ReactDOM.render(
  <AppStateProvider>
    <App />
  </AppStateProvider>,
  document.getElementById("root")
);

Nous pouvons rendre l'accès au state et dispatch en créant un hook, pour cela dans notre app-state-context.tsx, nous devons ajouter le useContext hook :

 import React, { createContext, useReducer, useContext } from "react"

export const useAppState = () => {
  return useContext(AppStateContext);
};

En quelques mots, cette fonction nous permet de récupérer la valeur de AppStateContext en utilisant useContext. Maintenant, nous pouvons importer cette fonction dans notre App.tsx, pour ensuite créer une boucle qui ajoutera dynamiquement nos données dans les colonnes :

 import React from "react";
import "./styles/index.scss";
import { AppContainer } from "./styled-components/container";
import { Card } from "./components/card";
import { Column } from "./components/column";
import { AddNewItem } from "./components/add-new-item";
import { useAppState } from "./states/app-state-context";

const App = () => {
  const { state } = useAppState();

  return (
    <AppContainer>
      {state.lists.map((list, i) => (
        <Column text={list.text} key={list.id} index={i} />
      ))}
      <AddNewItem toggleButtonText="+ Add another list" onAdd={console.log} />
    </AppContainer>
  );
};

export default App;

Nous avons un souci : index n'est pas compris dans notre typage ColumnProps présent dans le fichier Column.tsx. Voici les étapes qui vont changer :

  1. Notre interface doit ajouter une propriété index de type number
  2. Nous allons retirer le wrapping : React.PropsWithChildren de notre composant Column
  3. Nous allons importer notre state dans notre composant
  4. Nous devons maintenant retirer l'import du composant Card de notre fichier App.tsx et l'implémenter dans notre composant Column.

L'implémentation n'est pas compliquée, en suivant les étapes précédentes, vous devriez obtenir le code suivant :

 import React from "react";
import { ColumnContainer } from "../styled-components/container";
import { ColumnTitle } from "../styled-components/title";
import { AddNewItem } from "./add-new-item";
import { useAppState } from "../states/app-state-context";
import { Card } from "../components/card";

interface ColumnProps {
  text: string;
  index: number;
}

export const Column = ({ text, index }: ColumnProps) => {
  const { state } = useAppState();

  return (
    <ColumnContainer>
      <ColumnTitle>{text}</ColumnTitle>
      {state.lists[index].tasks.map((task) => (
        <Card text={task.text} key={task.id} />
      ))}
      <AddNewItem
        toggleButtonText="+ Add another task"
        onAdd={console.log}
        dark
      />
    </ColumnContainer>
  );
};

Il est maintenant temps d'utiliser notre reducer (expliqué plus haut) qui nous permettra d'appliquer des changements sur les objets. Pour cela, je vous propose de créer un fichier app-state-actions.tsx dans notre dossier states. Nous allons définir deux actions :

  1. Add_task : qui nous permettra d'ajouter des tâches dans notre state.
  2. Add_list : qui nous permettra d'ajouter des actions dans notre state.
 type Action =
| {
type: "ADD_LIST"
payload: string
}
| {
type: "ADD_TASK"
payload: { text: string; listId: string }
}

Ici, nous utilisons la technique Discriminated Union. Nous définissons un type Action qui comporte 2 interfaces séparées par une ligne verticale. En d'autres termes, chaque interface a un type de propriété qui est notre discriminant : typescript peut regarder cette propriété et nous dire quelles sont les autres propriétés de l'interface.

Créons notre Reducer qui utilisera ce state dans un fichier app-state-reducer.tsx :

 import { AppState } from "./app-state-context";
import { Action } from "./app-state-action";

export const appStateReducer = (state: AppState, action: Action): AppState => {
  switch (action.type) {
    case "ADD_LIST": {
      // Reducer logic here...
      return {
        ...state,
      };
    }
    case "ADD_TASK": {
      // Reducer logic here...
      return {
        ...state,
      };
    }
    default: {
      return state;
    }
  }
};

Nous ne devons pas définir des constantes pour nos actions, TypeScript donnera une erreur si nous essayons de comparer le type.

Nos actions et notre reducer sont prêts ! Il nous faut maintenant créer notre fonction dispatch qui permettra de lancer nos actions. Pour ce faire, dans app-state-context.tsx, nous allons ajouter cette fonction :

 import React, { createContext, useReducer, useContext } from "react";
import {Action} from "./app-state-action"
export const AppStateContext = createContext<AppStateContextProps>(
  {} as AppStateContextProps
);

interface AppStateContextProps {
  state: AppState;
  dispatch: React.Dispatch<Action>;
}

Ensuite, nous devons ajouter cette props dans notre app-state-provider.tsx :

 import { appData } from "./app-state-context";
import { useReducer } from "react";
import { AppStateContext } from "./app-state-context";
import { appStateReducer } from "./app-state-reducer";

export const AppStateProvider = ({ children }: React.PropsWithChildren<{}>) => {
  const [state, dispatch] = useReducer(appStateReducer, appData);

  return (
    <AppStateContext.Provider value={{ state, dispatch }}>
      {children}
    </AppStateContext.Provider>
  );
};

Il y a quelques changements notables :

  • On importe notre state et dispatch à travers le hooks React : useReducer
  • On importe notre reducer défini précédemment
  • On passe les deux valeurs à travers les props

Nos actions manquent de logique métier. Nous allons implémenter ADD_LIST, pour créer une nouvelle instance de l'objet à la volée. Que devons-nous faire ?

  • Nous allons utiliser des opérateurs (les spreads) pour obtenir tous les champs de l'objet précédent
  • Nous allons appliquer la liste de champs dans un nouveau tableau avec les anciens champs + les nouveaux
  • Nous devons donner les champs suivant : text, id et taches pour créer une nouvelle colonne
  • La création de l'ID doit se faire dynamiquement via nanoid qui est une librairie gratuite

Importons nanoid :

 npm install nanoid

Ensuite nous pouvons implémenter notre action pour quelle ressemble à ceci :

 <pre>
 <code id="htmlViewer" style="color:rgb(220, 220, 220); font-weight:400;background-color:rgb(30, 30, 30);background:rgb(30, 30, 30);display:block;padding: .5em;"><span style="color:rgb(86, 156, 214); font-weight:400;">import</span> { <span class="hljs-title class_">AppState</span> } <span style="color:rgb(86, 156, 214); font-weight:400;">from</span> <span style="color:rgb(214, 157, 133); font-weight:400;">&quot;./app-state-context&quot;</span>;
<span style="color:rgb(86, 156, 214); font-weight:400;">import</span> { <span class="hljs-title class_">Action</span> } <span style="color:rgb(86, 156, 214); font-weight:400;">from</span> <span style="color:rgb(214, 157, 133); font-weight:400;">&quot;./app-state-action&quot;</span>;
<span style="color:rgb(86, 156, 214); font-weight:400;">import</span> { nanoid } <span style="color:rgb(86, 156, 214); font-weight:400;">from</span> <span style="color:rgb(214, 157, 133); font-weight:400;">&quot;nanoid&quot;</span>;

<span style="color:rgb(86, 156, 214); font-weight:400;">export</span> <span style="color:rgb(86, 156, 214); font-weight:400;">const</span> appStateReducer = (<span style="color:rgb(156, 220, 254); font-weight:400;">state</span>: <span class="hljs-title class_">AppState</span>, <span style="color:rgb(156, 220, 254); font-weight:400;">action</span>: <span class="hljs-title class_">Action</span>): <span style="color:rgb(220, 220, 220); font-weight:400;"><span style="color:rgb(220, 220, 220); font-weight:400;">AppState</span> =&gt;</span> {
  <span style="color:rgb(86, 156, 214); font-weight:400;">switch</span> (action.<span style="color:rgb(220, 220, 220); font-weight:400;">type</span>) {
    <span style="color:rgb(86, 156, 214); font-weight:400;">case</span> <span style="color:rgb(214, 157, 133); font-weight:400;">&quot;ADD_LIST&quot;</span>: {
      <span style="color:rgb(86, 156, 214); font-weight:400;">return</span> {
        ...state,
        <span style="color:rgb(156, 220, 254); font-weight:400;">lists</span>: [
          ...state.<span style="color:rgb(220, 220, 220); font-weight:400;">lists</span>,
          { <span style="color:rgb(156, 220, 254); font-weight:400;">id</span>: <span class="hljs-title function_">nanoid</span>(), <span style="color:rgb(156, 220, 254); font-weight:400;">text</span>: action.<span style="color:rgb(220, 220, 220); font-weight:400;">payload</span>, <span style="color:rgb(156, 220, 254); font-weight:400;">tasks</span>: [] },
        ],
      };
    }
    <span style="color:rgb(86, 156, 214); font-weight:400;">case</span> <span style="color:rgb(214, 157, 133); font-weight:400;">&quot;ADD_TASK&quot;</span>: {
      <span style="color:rgb(87, 166, 74); font-weight:400;">// Reducer logic here...</span>
      <span style="color:rgb(86, 156, 214); font-weight:400;">return</span> {
        ...state,
      };
    }
    <span style="color:rgb(156, 220, 254); font-weight:400;">default</span>: {
      <span style="color:rgb(86, 156, 214); font-weight:400;">return</span> state;
    }
  }
};</code></pre>

L'action ADD_TASK sera un peu plus compliquée. En effet, ces tasks doivent être ajoutées dans des listes de tâches. Nous devrons donc trouver la liste en question par son ID à travers la méthode findItemIndexById que nous pouvons ajouter dans notre dossier util. Créons notre fichier arrayUtils.ts :

 interface Item {
  id: string;
}

export const findItemIndexById = <T extends Item>(items: T[], id: string) => {
  return items.findIndex((item: T) => item.id === id);
};

Nous avons utilisé un type générique qui étend Item afin de contraindre notre type à contenir les champs définis dans notre interface Item. Maintenant que nous avons cet objet, il nous reste à implémenter notre objet. Pour cela, nous allons procéder comme suit :

Nous devons créer une fonction qui permettra d'overrider la fonction push : overrideItemAtIndex. Mais pourquoi ? Uniquement parce que push crée une mutation directe de l'objet ce que nous ne voulons plus. Pour réaliser cette fonction, nous allons créer une nouvelle méthode dans notre util.

Implémentation :

 export function overrideItemAtIndex<T>(
  array: T[],
  newItem: T,
  targetIndex: number
) {
  return array.map((item, index) => {
    if (index !== targetIndex) {
      return item;
    }
    return newItem;
  });
}

Concernant notre action, nous devons créer une nouvelle liste d'objets avec la nouvelle tâche à ajouter. Ensuite, nous utilisons l'util précédemment défini afin d'overrider la liste cible avec sa version mise à jour.

Bien après ces explications, nous pouvons définir notre action comme suit :

 import { AppState } from "./app-state-context";
import { Action } from "./app-state-action";
import { nanoid } from "nanoid";
import { overrideItemAtIndex, findItemIndexById } from "../utils/array-utils";

export const appStateReducer = (state: AppState, action: Action): AppState => {
  switch (action.type) {
    case "ADD_LIST": {
      return {
        ...state,
        lists: [
          ...state.lists,
          { id: nanoid(), text: action.payload, tasks: [] },
        ],
      };
    }
    case "ADD_TASK": {
      const targetListIndex = findItemIndexById(
        state.lists,
        action.payload.listId
      );
      const targetList = state.lists[targetListIndex];
      const updatedTargetList = {
        ...targetList,
        tasks: [
          ...targetList.tasks,
          { id: nanoid(), text: action.payload.text },
        ],
      };
      return {
        ...state,
        lists: overrideItemAtIndex(
          state.lists,
          updatedTargetList,
          targetListIndex
        ),
      };
    }
    default: {
      return state;
    }
  }
};

Maintenant, adaptons notre App pour utiliser la fonction de dispatch de notre hook useAppState :

 import React from "react";
import "./styles/index.scss";
import { AppContainer } from "./styled-components/container";
import { Column } from "./components/column";
import { AddNewItem } from "./components/add-new-item";
import { useAppState } from "./states/app-state-context";

const App = () => {
  const { state, dispatch } = useAppState();

  return (
    <AppContainer>
      {state.lists.map((list, i) => (
        <Column id={list.id} text={list.text} key={list.id} index={i} />
      ))}
      <AddNewItem
        toggleButtonText="+ Add another list"
        onAdd={(text) => dispatch({ type: "ADD_LIST", payload: text })}
      />
    </AppContainer>
  );
};

export default App;

Nous devons maintenant mettre à jour ColumnProps afin d'accepter le champ id :

 interface ColumnProps {
  text: string;
  index: number;
  id: string;
}

Cette prop créée, nous devons aussi mettre notre composant à jour :

 export const Column = ({ text, index, id }: ColumnProps) => {
  const { state, dispatch } = useAppState();
  return (
    <ColumnContainer>
      <ColumnTitle>{text}</ColumnTitle>
      {state.lists[index].tasks.map((task, i) => (
        <Card text={task.text} key={task.id} index={i} />
      ))}
      <AddNewItem
        toggleButtonText="+ Add another card"
        onAdd={(text) =>
          dispatch({ type: "ADD_TASK", payload: { text, listId: id } })
        }
        dark
      />
    </ColumnContainer>
  );
};;export const Column = ({ text, index, id }: ColumnProps) => {
  const { state, dispatch } = useAppState();
  return (
    <ColumnContainer>
      <ColumnTitle>{text}</ColumnTitle>
      {state.lists[index].tasks.map((task, i) => (
        <Card text={task.text} key={task.id} index={i} />
      ))}
      <AddNewItem
        toggleButtonText="+ Add another card"
        onAdd={(text) =>
          dispatch({ type: "ADD_TASK", payload: { text, listId: id } })
        }
        dark
      />
    </ColumnContainer>
  );
};export const Column = ({ text, index, id }: ColumnProps) => {
  const { state, dispatch } = useAppState();
  return (
    <ColumnContainer>
      <ColumnTitle>{text}</ColumnTitle>
      {state.lists[index].tasks.map((task, i) => (
        <Card text={task.text} key={task.id} index={i} />
      ))}
      <AddNewItem
        toggleButtonText="+ Add another card"
        onAdd={(text) =>
          dispatch({ type: "ADD_TASK", payload: { text, listId: id } })
        }
        dark
      />
    </ColumnContainer>
  );
};

CardProp requière aussi notre attention :

 interface CardProps {
  text: string;
  index: number;
}

On peut maintenant mettre à jour le composant :

 export const Column = ({ text, index, id }: ColumnProps) => {
  const { state, dispatch } = useAppState();
  return (
    <ColumnContainer>
      <ColumnTitle>{text}</ColumnTitle>
      {state.lists[index].tasks.map((task, i) => (
        <Card text={task.text} key={task.id} index={i} />
      ))}
      <AddNewItem
        toggleButtonText="+ Add another card"
        onAdd={(text) =>
          dispatch({ type: "ADD_TASK", payload: { text, listId: id } })
        }
        dark
      />
    </ColumnContainer>
  );
};

Voyons le résultat final :

Fin de la partie 2

Bien, nous avons créé notre state et notre reducer. Dans le chapitre suivant, nous allons :

  • Créer la fonctionnalité de Drag and drop pour nos colonnes
  • Ajouter les actions nécessaires pour ce drag and drop
  • Override la prévisualisation du drag and drop pour créer la nôtre

La suite de ce tutoriel se trouve ici : React - Trello - Part 3.

Developpeur et architecte passionné, qui souhaite partagé son univers et ses découvertes afin de rendre les choses plus simple pour chacun

URLs

Check les divers liens pour cet article