Fermer

août 3, 2020

Configuration de Redux pour une utilisation dans une application du monde réel


À propos de l'auteur

J'adore créer des logiciels pour le Web, écrire sur les technologies Web et jouer à des jeux vidéo.
En savoir plus sur
Jerry

Redux est une bibliothèque robuste de gestion d'état pour les applications Javascript d'une seule page. Il est décrit dans la documentation officielle comme un conteneur d'état prévisible pour les applications Javascript et il est assez simple d'apprendre les concepts et d'implémenter Redux dans une application simple. Passer d'une simple application de comptage à une application du monde réel, cependant, peut être tout à fait le saut.

Redux est une bibliothèque importante dans l'écosystème React, et presque la bibliothèque par défaut à utiliser lorsque vous travaillez sur des applications React qui impliquent une gestion d'état. En tant que tel, l'importance de savoir comment cela fonctionne ne peut être surestimée.

Ce guide guidera le lecteur dans la configuration de Redux dans une application React assez complexe et présentera au lecteur la configuration des «meilleures pratiques» en cours de route. Cela sera particulièrement bénéfique pour les débutants, et pour tous ceux qui souhaitent combler les lacunes dans leurs connaissances de Redux.

Présentation de Redux

Redux est une bibliothèque qui vise à résoudre le problème de la gestion des états dans les applications JavaScript en imposant des restrictions sur comment et quand les mises à jour d'état peuvent avoir lieu. Ces restrictions sont formées des «trois principes» de Redux qui sont:

  • Source unique de vérité
    L'ensemble de l'état de votre application est conservé dans un magasin Redux . Cet état peut être représenté visuellement comme une arborescence avec un seul ancêtre, et le magasin fournit des méthodes pour lire l'état actuel et s'abonner aux modifications de n'importe où dans votre application.

  • L'état est en lecture seule
    La seule façon de changer l'état est d'envoyer les données sous la forme d'un objet simple, appelé action. Vous pouvez penser aux actions comme un moyen de dire à l'état: «J'ai des données que je voudrais insérer / mettre à jour / supprimer».

  • Des modifications sont apportées avec des fonctions pures
    Pour changer l'état de votre application, vous écrivez une fonction qui prend l'état précédent et une action et renvoie un nouvel objet d'état comme état suivant. Cette fonction est appelée un réducteur et c'est une fonction pure car elle renvoie la même sortie pour un ensemble donné d'entrées.

Le dernier principe est le plus important dans Redux, et c'est là que le la magie de Redux se produit. Les fonctions de réduction ne doivent pas contenir de code imprévisible, ni effectuer d'effets secondaires tels que des requêtes réseau, et ne doivent pas muter directement l'objet d'état.

Redux est un excellent outil, comme nous l'apprendrons plus tard dans ce guide, mais il ne le fait pas. t venir sans ses défis ou ses compromis. Pour aider à rendre le processus d'écriture Redux efficace et plus agréable, l'équipe Redux propose une boîte à outils qui résume le processus de configuration d'un magasin Redux et fournit des modules complémentaires et des utilitaires Redux utiles qui aident à simplifier le code d'application. Par exemple, la bibliothèque utilise Immer.js une bibliothèque qui vous permet d'écrire sous le capot une logique de mise à jour immuable «mutative».

Lecture recommandée : [19659019] De meilleurs réducteurs avec Immer

Dans ce guide, nous explorerons Redux en créant une application qui permet aux utilisateurs authentifiés de créer et de gérer des agendas numériques.

Building Diaries.app

Comme indiqué dans Dans la section précédente, nous examinerons de plus près Redux en créant une application qui permet aux utilisateurs de créer et de gérer des agendas. Nous allons construire notre application en utilisant React, et nous allons configurer Mirage comme notre serveur de simulation d'API puisque nous n'aurons pas accès à un vrai serveur dans ce guide.

Démarrage d'un projet et installation de dépendances

Allons-y commencé notre projet. Tout d'abord, amorcez une nouvelle application React en utilisant create-react-app :

En utilisant npx:

 npx create-react-app diaries-app --template typescript

Nous commençons avec le modèle TypeScript, car nous pouvons améliorer notre expérience de développement en écrivant du code de type sécurisé.

Maintenant, installons les dépendances dont nous aurons besoin. Accédez au répertoire de votre projet nouvellement créé

 cd diaries-app

Et exécutez les commandes suivantes:

 npm install --save redux react-redux @ reduxjs / toolkit
 npm install --save axios react-router-dom react-hook-form yup dayjs markdown-to-jsx sweetalert2
 npm install --save-dev miragejs @ types / react-redux @ types / react-router-dom @ types / yup @ types / markdown-to-jsx

La première commande installera Redux, React-Redux (liaisons React officielles pour Redux) et la boîte à outils Redux.

La deuxième commande installe des packages supplémentaires qui seront utiles pour l'application que nous allons construire mais qui sont non requis pour travailler avec Redux.

La dernière commande installe Mirage et les déclarations de type pour les paquets que nous avons installés en tant que devDependencies.

Décrire l'état initial de l'application

Passons en revue les exigences de notre application en détail. L'application permettra aux utilisateurs authentifiés de créer ou de modifier des agendas existants. Les agendas sont privés par défaut, mais ils peuvent être rendus publics. Enfin, les entrées du journal seront triées en fonction de leur date de dernière modification.

Cette relation devrait ressembler à ceci:

Un aperçu du modèle de données de l'application. ( Grand aperçu )

Forts de ces informations, nous pouvons maintenant modéliser l'état de notre application. Tout d'abord, nous allons créer une interface pour chacune des ressources suivantes: User Diary et DiaryEntry . Les interfaces dans Typescript décrivent la forme d'un objet.

Allez-y et créez un nouveau répertoire nommé interfaces dans le sous-répertoire src de votre application:

 ] interfaces cd src && mkdir

Ensuite, exécutez les commandes suivantes dans le répertoire que vous venez de créer:

 touchez entry.interface.ts
touch diary.interface.ts
touchez user.interface.ts

Cela créera trois fichiers nommés entry.interface.ts diary.interface.ts et user.interface.ts respectivement. Je préfère conserver les interfaces qui seraient utilisées à plusieurs endroits dans mon application dans un même emplacement.

Ouvrez entry.interface.ts et ajoutez le code suivant pour configurer l'entrée ] interface:

 interface d'exportation Entrée {
  id?: chaîne;
  titre: chaîne;
  contenu: chaîne;
  createdAt?: chaîne;
  updatedAt?: chaîne;
  diaryId?: chaîne;
}

Une entrée de journal typique aura un titre et du contenu, ainsi que des informations sur la date de sa création ou de sa dernière mise à jour. Nous reviendrons à la propriété diaryId plus tard.

Ensuite, ajoutez ce qui suit à diary.interface.ts :

 export interface Diary {
  id?: chaîne;
  titre: chaîne;
  type: 'privé' | 'Publique';
  createdAt?: chaîne;
  updatedAt?: chaîne;
  userId?: chaîne;
  entryIds: chaîne [] | nul;
}

Ici, nous avons une propriété de type qui attend une valeur exacte de «privé» ou de «public», car les agendas doivent être privés ou publics. Toute autre valeur générera une erreur dans le compilateur TypeScript.

Nous pouvons maintenant décrire notre objet User dans le fichier user.interface.ts comme suit:

 interface d'exportation Utilisateur {
  id?: chaîne;
  nom d'utilisateur: chaîne;
  email: chaîne;
  mot de passe?: chaîne;
  diaryIds: chaîne [] | nul;
}

Une fois nos définitions de type terminées et prêtes à être utilisées dans notre application, configurons notre serveur d'API simulé à l'aide de Mirage.

Configuration de la simulation d'API avec MirageJS

Puisque ce didacticiel est axé sur Redux, nous n'irons pas dans les détails de la configuration et de l'utilisation de Mirage dans cette section. Veuillez consulter cette excellente série si vous souhaitez en savoir plus sur Mirage.

Pour commencer, accédez à votre répertoire src et créez un fichier nommé serveur . ts en exécutant les commandes suivantes:

 mkdir -p services / mirage
services cd / mirage

# ~ / diaries-app / src / services / mirage
touch server.ts

Ensuite, ouvrez le fichier server.ts et ajoutez le code suivant:

 import {Server, Model, Factory, comesTo, hasMany, Response} from 'miragejs';

export const handleErrors = (error: any, message = 'Une erreur est survenue') => {
  renvoie une nouvelle réponse (400, indéfini, {
    Les données: {
      message,
      isError: vrai,
    },
  });
};

export const setupServer = (env?: chaîne): Serveur => {
  retourner un nouveau serveur ({
    environnement: env ?? 'développement',

    des modèles: {
      entrée: Model.extend ({
        journal: appartient à (),
      }),
      journal: Model.extend ({
        entrée: hasMany (),
        utilisateur: appartient à (),
      }),
      utilisateur: Model.extend ({
        journal: hasMany (),
      }),
    },

    des usines: {
      utilisateur: Factory.extend ({
        nom d'utilisateur: 'test',
        mot de passe: 'mot de passe',
        email: 'test@email.com',
      }),
    },

    graines: (serveur): any => {
      server.create ('utilisateur');
    },

    routes (): void {
      this.urlPrefix = 'https://diaries.app';
    },
  });
};

Dans ce fichier, nous exportons deux fonctions. Une fonction utilitaire pour gérer les erreurs et setupServer () qui renvoie une nouvelle instance de serveur. La fonction setupServer () prend un argument facultatif qui peut être utilisé pour modifier l'environnement du serveur. Vous pouvez l'utiliser pour configurer Mirage pour des tests ultérieurs.

Nous avons également défini trois modèles dans la propriété models du serveur: User Diary et Entrée . Souvenez-vous qu'auparavant nous avons configuré l'interface Entry avec une propriété nommée diaryId . Cette valeur sera automatiquement définie sur l'ID dans lequel l'entrée est enregistrée. Mirage utilise cette propriété pour établir une relation entre une Entrée et un Agenda . La même chose se produit également lorsqu'un utilisateur crée un nouveau journal: userId est automatiquement défini sur l'ID de cet utilisateur.

Nous avons amorcé la base de données avec un utilisateur par défaut et configuré Mirage pour intercepter toutes les demandes de notre application à partir de avec https://diaries.app . Notez que nous n’avons encore configuré aucun gestionnaire d’itinéraires . Allons-y et créons-en quelques-uns.

Assurez-vous que vous êtes dans le répertoire src / services / mirage puis créez un nouveau répertoire nommé routes à l'aide de la commande suivante:

 ] # ~ / diaries-app / src / services / mirage
routes mkdir

cd dans le répertoire nouvellement créé et créez un fichier nommé user.ts :

 cd routes
toucher user.ts

Ensuite, collez le code suivant dans le fichier user.ts :

 import {Response, Request} from 'miragejs';
import {handleErrors} de '../server';
import {Utilisateur} de '../../../interfaces/user.interface';
importer {randomBytes} depuis 'crypto';

const generateToken = () => randomBytes (8) .toString ('hex');

interface d'exportation AuthResponse {
  jeton: chaîne;
  utilisateur: utilisateur;
}

const login = (schéma: any, req: Request): AuthResponse | Réponse => {
  const {nom d'utilisateur, mot de passe} = JSON.parse (req.requestBody);
  const user = schema.users.findBy ({nom d'utilisateur});
  if (! utilisateur) {
    return handleErrors (null, 'Aucun utilisateur avec ce nom d'utilisateur n'existe');
  }
  if (mot de passe! == user.password) {
    return handleErrors (null, 'Le mot de passe est incorrect');
  }
  jeton const = generateToken ();
  revenir {
    user: user.attrs en tant qu'utilisateur,
    jeton,
  };
};

const signup = (schéma: any, req: Request): AuthResponse | Réponse => {
  const data = JSON.parse (req.requestBody);
  const exUser = schema.users.findBy ({nom d'utilisateur: data.username});
  if (exUser) {
    return handleErrors (null, 'Un utilisateur avec ce nom d'utilisateur existe déjà.');
  }
  const user = schema.users.create (données);
  jeton const = generateToken ();
  revenir {
    user: user.attrs en tant qu'utilisateur,
    jeton,
  };
};

export par défaut {
  s'identifier,
  s'inscrire,
};

Les méthodes login et signup reçoivent ici une classe Schema et un faux objet Request et, après validation du mot de passe ou vérification que la connexion n'existe pas déjà, renvoie l'utilisateur existant ou un nouvel utilisateur respectivement. Nous utilisons l'objet Schema pour interagir avec l'ORM de Mirage, tandis que l'objet Request contient des informations sur la requête interceptée, y compris le corps de la requête et les en-têtes.

Ensuite, ajoutons des méthodes de travail avec agendas et entrées d'agenda. Créez un fichier nommé diary.ts dans votre répertoire routes :

 touch diary.ts

Mettez à jour le fichier avec les méthodes suivantes pour utiliser les ressources Diary :

 export const create = (
  schéma: tout,
  req: Demande
): {utilisateur: Utilisateur; journal: Journal} | Réponse => {
  essayez {
    const {title, type, userId} = JSON.parse (req.requestBody) as Partial <
      Diary
    >;
    const exUser = schema.users.findBy ({id: userId});
    if (! exUser) {
      return handleErrors (null, 'Aucun utilisateur n'existe.');
    }
    const now = dayjs (). format ();
    const diary = exUser.createDiary ({
      Titre,
      type,
      createdAt: maintenant,
      mis à jour à: maintenant,
    });
    revenir {
      utilisateur: {
        ... exUser.attrs,
      },
      agenda: diary.attrs,
    };
  } catch (erreur) {
    return handleErrors (erreur, 'Impossible de créer le journal.');
  }
};

export const updateDiary = (schéma: any, req: Request): Journal | Réponse => {
  essayez {
    const diary = schema.diaries.find (req.params.id);
    const data = JSON.parse (req.requestBody) as Partial ;
    const now = dayjs (). format ();
    diary.update ({
      ...Les données,
      mis à jour à: maintenant,
    });
    retourne diary.attrs comme Journal;
  } catch (erreur) {
    return handleErrors (erreur, 'Échec de la mise à jour du journal.');
  }
};

export const getDiaries = (schéma: any, req: Request): Agenda [] | Réponse => {
  essayez {
    const user = schema.users.find (req.params.id);
    renvoie user.diary sous forme de journal [];
  } catch (erreur) {
    return handleErrors (error, 'Impossible d'obtenir les journaux des utilisateurs.');
  }
};

Ensuite, ajoutons quelques méthodes pour travailler avec les entrées du journal:

 export const addEntry = (
  schéma: tout,
  req: Demande
): {agenda: Agenda; entrée: Entrée} | Réponse => {
  essayez {
    const diary = schema.diaries.find (req.params.id);
    const {title, content} = JSON.parse (req.requestBody) as Partial ;
    const now = dayjs (). format ();
    entrée const = journal.createEntry ({
      Titre,
      contenu,
      createdAt: maintenant,
      mis à jour à: maintenant,
    });
    diary.update ({
      ... agenda.attrs,
      mis à jour à: maintenant,
    });
    revenir {
      agenda: diary.attrs,
      entrée: entry.attrs,
    };
  } catch (erreur) {
    return handleErrors (error, 'Impossible d'enregistrer l'entrée.');
  }
};

export const getEntries = (
  schéma: tout,
  req: Demande
): {entrées: Entrée []} | Réponse => {
  essayez {
    const diary = schema.diaries.find (req.params.id);
    retour journal.entry;
  } catch (erreur) {
    return handleErrors (error, 'Impossible d'obtenir les entrées du journal.');
  }
};

export const updateEntry = (schéma: any, req: Request): Entrée | Réponse => {
  essayez {
    entrée const = schema.entries.find (req.params.id);
    const data = JSON.parse (req.requestBody) as Partial ;
    const now = dayjs (). format ();
    entry.update ({
      ...Les données,
      mis à jour à: maintenant,
    });
    retourne entry.attrs comme Entry;
  } catch (erreur) {
    return handleErrors (erreur, 'Échec de la mise à jour de l'entrée.');
  }
};

Enfin, ajoutons les importations nécessaires en haut du fichier:

 import {Response, Request} from 'miragejs';
import {handleErrors} de '../server';
import {Agenda} de '../../../interfaces/diary.interface';
import {Entrée} de '../../../interfaces/entry.interface';
importer les jours de «dayjs»;
import {Utilisateur} de '../../../interfaces/user.interface';

Dans ce fichier, nous avons exporté des méthodes pour travailler avec les modèles Agenda et Entry . Dans la méthode create nous appelons une méthode nommée user.createDiary () pour enregistrer un nouveau journal et l'associer à un compte utilisateur.

Le addEntry ] et updateEntry créent et associent correctement une nouvelle entrée à un journal ou mettent respectivement à jour les données d'une entrée existante. Ce dernier met également à jour la propriété updatedAt de l’entrée avec l’horodatage actuel. La méthode updateDiary met également à jour un journal avec l'horodatage de la modification. Plus tard, nous trierons les enregistrements que nous recevons de notre requête réseau avec cette propriété.

Nous avons également une méthode getDiaries qui récupère les journaux d'un utilisateur et une méthode getEntries qui récupère les entrées d'un journal sélectionné.

Nous pouvons maintenant mettre à jour notre serveur pour utiliser les méthodes que nous venons de créer. Ouvrez server.ts pour inclure les fichiers:

 import {Server, Model, Factory, comesTo, hasMany, Response} from 'miragejs';

importer un utilisateur depuis './routes/user';
import * comme agenda depuis './routes/diary';

Ensuite, mettez à jour la propriété route du serveur avec les routes que nous voulons gérer:

 export const setupServer = (env ?: string): Server => {
  retourner un nouveau serveur ({
    // ...
    routes (): void {
      this.urlPrefix = 'https://diaries.app';

      this.get ('/ journaux / entrées /: id', diary.getEntries);
      this.get ('/ diaries /: id', diary.getDiaries);

      this.post ('/ auth / login', user.login);
      this.post ('/ auth / signup', user.signup);

      this.post ('/ diaries /', diary.create);
      this.post ('/ journaux / entrée /: id', journal.addEntry);

      this.put ('/ diaries / entrée /: id', diary.updateEntry);
      this.put ('/ diaries /: id', diary.updateDiary);
    },
  });
};

Avec ce changement, lorsqu'une requête réseau de notre application correspond à l'un des gestionnaires de route, Mirage intercepte la requête et appelle les fonctions de gestionnaire de route respectives.

Ensuite, nous allons faire connaître le serveur à notre application. . Ouvrez src / index.tsx et importez la méthode setupServer () :

 import {setupServer} depuis './services/mirage/server';

Et ajoutez le code suivant avant ReactDOM.render () :

 if (process.env.NODE_ENV === 'development') {
  setupServer ();
}

La ​​vérification dans le bloc de code ci-dessus garantit que notre serveur Mirage ne fonctionnera que pendant que nous sommes en mode développement.

Une dernière chose que nous devons faire avant de passer aux bits Redux est de configurer un Axios personnalisé instance à utiliser dans notre application. Cela aidera à réduire la quantité de code que nous devrons écrire plus tard.

Créez un fichier nommé api.ts sous src / services et ajoutez-y le code suivant:

 import axios, {AxiosInstance, AxiosResponse, AxiosError} de 'axios';
import {showAlert} de '../util';

const http: AxiosInstance = axios.create ({
  baseURL: 'https://diaries.app',
});

http.defaults.headers.post ['Content-Type'] = 'application / json';

http.interceptors.response.use (
  async (réponse: AxiosResponse): Promise  => {
    if (response.status> = 200 && response.status < 300) {
      return response.data;
    }
  },
  (error: AxiosError) => {
    const {réponse, requête}: {
      réponse?: AxiosResponse;
      request?: XMLHttpRequest;
    } = erreur;
    if (réponse) {
      if (response.status> = 400 && response.status <500) {
        showAlert (response.data?.data?.message, 'erreur');
        return null;
      }
    } else if (demande) {
      showAlert ('La demande a échoué. Veuillez réessayer.', 'erreur');
      return null;
    }
    return Promise.reject (erreur);
  }
);

export par défaut http;
 

Dans ce fichier, nous exportons une instance Axios modifiée pour inclure l'URL d'API de notre application, https://diaries.app . Nous avons configuré un intercepteur pour gérer les réponses de succès et d'erreur, et nous affichons les messages d'erreur à l'aide d'un sweetalert toast que nous configurerons à l'étape suivante.

Créez un fichier nommé util.ts dans votre répertoire src et collez-y le code suivant:

 import Swal, {SweetAlertIcon} from 'sweetalert2';

export const showAlert = (titleText = 'Quelque chose est arrivé.', alertType ?: SweetAlertIcon): void => {
  Swal.fire ({
    titleText,
    position: 'haut de gamme',
    minuterie: 3000,
    timerProgressBar: vrai,
    toast: vrai,
    showConfirmButton: faux,
    showCancelButton: vrai,
    cancelButtonText: 'Ignorer',
    icône: alertType,
    showClass: {
      popup: 'swal2-noanimation',
      toile de fond: 'swal2-noanimation',
    },
    hideClass: {
      apparaitre: '',
      toile de fond: '',
    },
  });
};

Ce fichier exporte une fonction qui affiche un toast chaque fois qu'elle est appelée. La fonction accepte des paramètres pour vous permettre de définir le message et le type de toast. Par exemple, nous affichons un toast d'erreur dans l'intercepteur d'erreur de réponse Axios comme ceci:

 showAlert (response.data?.data?.message, 'error');

Désormais, lorsque nous faisons des requêtes depuis notre application en mode développement, elles seront interceptées et gérées par Mirage à la place. Dans la section suivante, nous allons configurer notre boutique Redux en utilisant la boîte à outils Redux.

Configurer une boutique Redux

Dans cette section, nous allons configurer notre boutique en utilisant les exportations suivantes de la boîte à outils Redux: configureStore () getDefaultMiddleware () et createSlice () . Avant de commencer, nous devrions examiner en détail ce que font ces exportations.

configureStore () est une abstraction de la fonction Redux createStore () qui simplifie votre code. Il utilise createStore () en interne pour configurer votre boutique avec quelques outils de développement utiles:

 export const store = configureStore ({
  reducer: rootReducer, // une seule fonction de réduction ou un objet de réducteurs de tranche
});

La fonction createSlice () permet de simplifier le processus de création de créateurs d'action et de réducteurs de tranches. Il accepte un état initial, un objet plein de fonctions de réducteur et un «nom de tranche», et génère automatiquement des créateurs d'actions et des types d'actions correspondant aux réducteurs et à votre état. Il renvoie également une fonction de réducteur unique, qui peut être transmise à la fonction combinerReducers () de Redux en tant que «réducteur de tranche».

Rappelez-vous que l'état est un seul arbre et qu'un seul réducteur de racine gère les changements à cet arbre. Pour la maintenabilité, il est recommandé de diviser votre réducteur racine en «tranches» et de demander à un «réducteur de tranche» de fournir une valeur initiale et de calculer les mises à jour pour une tranche correspondante de l'état. Ces tranches peuvent être réunies en une seule fonction de réduction en utilisant combineReducers () .

Il existe des options supplémentaires pour configurer le magasin . Par exemple, vous pouvez transmettre un tableau de votre propre middleware à configureStore () ou démarrer votre application à partir d'un état enregistré à l'aide de l'option preloadedState . Lorsque vous fournissez l'option middleware vous devez définir all le middleware que vous souhaitez ajouter au magasin. Si vous souhaitez conserver les valeurs par défaut lors de la configuration de votre boutique, vous pouvez utiliser getDefaultMiddleware () pour obtenir la liste par défaut des middlewares:

 export const store = configureStore ({
  // ...
  middleware: [...getDefaultMiddleware(), customMiddleware],
});

Nous allons maintenant mettre en place notre boutique. Nous adopterons une approche «ducks-style» pour structurer nos fichiers, en suivant spécifiquement les directives pratiques de l'exemple d'application Github Issues . Nous organiserons notre code de manière à ce que les composants associés, ainsi que les actions et les réducteurs, résident dans le même répertoire. L'objet d'état final ressemblera à ceci:

 type RootState = {
  auth: {
    jeton: chaîne | nul;
    isAuthenticated: booléen;
  };
  agendas: Diary [];
  entrées: Entrée [];
  utilisateur: Utilisateur | nul;
  éditeur: {
    canEdit: booléen;
    currentEditing: Entrée | nul;
    activeDiaryId: chaîne | nul;
  };
}

Pour commencer, créez un nouveau répertoire nommé features sous votre répertoire src :

 # ~ / diaries-app / src
Fonctionnalités de mkdir

Ensuite, cd dans les fonctionnalités et créez des répertoires nommés auth diary et entry :

 cd features
entrée de journal d'authentification mkdir

cd dans le répertoire auth et créez un fichier nommé authSlice.ts :

 cd auth
# ~ / diaries-app / src / features / auth
touchez authSlice.ts

Ouvrez le fichier et collez-y ce qui suit:

 import {createSlice, PayloadAction} de '@ reduxjs / toolkit';

interface AuthState {
  jeton: chaîne | nul;
  isAuthenticated: booléen;
}

const initialState: AuthState = {
  jeton: nul,
  isAuthenticated: faux,
};

const auth = createSlice ({
  nom: 'auth',
  Etat initial,
  réducteurs: {
    saveToken (état, {payload}: PayloadAction ) {
      if (charge utile) {
        state.token = charge utile;
      }
    },
    clearToken (état) {
      state.token = null;
    },
    setAuthState (état, {charge utile}: PayloadAction ) {
      state.isAuthenticated = charge utile;
    },
  },
});

export const {saveToken, clearToken, setAuthState} = auth.actions;
export par défaut auth.reducer;
  

Dans ce fichier, nous créons une tranche pour la propriété auth de l'état de notre application à l'aide de la fonction createSlice () introduite précédemment. La propriété reducers contient une carte de fonctions de réduction pour mettre à jour les valeurs dans la tranche d'authentification. L'objet renvoyé contient des créateurs d'actions générés automatiquement et un réducteur de tranche unique. Nous aurions besoin de les utiliser dans d'autres fichiers donc, en suivant le «modèle des canards», nous effectuons des exportations nommées des créateurs d'action et une exportation par défaut de la fonction de réduction.

Configurons les tranches de réduction restantes en fonction de l'application état que nous avons vu plus tôt. Commencez par créer un fichier nommé userSlice.ts dans le répertoire auth et ajoutez-y le code suivant:

 import {createSlice, PayloadAction} de '@ reduxjs / toolkit';
import {Utilisateur} de '../../interfaces/user.interface';

utilisateur const = createSlice ({
  nom: 'utilisateur',
  initialState: null en tant qu'utilisateur | nul,
  réducteurs: {
    setUser (état, {payload}: PayloadAction ) {
      return state = (payload! = null)? charge utile: null;
    },
  },
});

export const {setUser} = user.actions;
exporter l'utilisateur par défaut.reducer;

Cela crée un réducteur de tranche pour la propriété user dans le magasin de l'application. La fonction de réduction setUser accepte une charge utile contenant des données utilisateur et met à jour l'état avec elle. Lorsqu'aucune donnée n'est transmise, nous définissons la propriété utilisateur de l'état sur null .

Ensuite, créez un fichier nommé diariesSlice.ts sous src / features / diary ]:

 # ~ / diaries-app / src / features
agenda cd
journal tactileSlice.ts

Ajoutez le code suivant au fichier:

 import {createSlice, PayloadAction} de '@ reduxjs / toolkit';
import {Agenda} de '../../interfaces/diary.interface';

const diaries = createSlice ({
  nom: 'agendas',
  initialState: [] comme journal [],
  réducteurs: {
    addDiary (état, {payload}: PayloadAction ) {
      const diariesToSave = payload.filter ((journal) => {
        return state.findIndex ((item) => item.id === diary.id) === -1;
      });
      state.push (... diariesToSave);
    },
    updateDiary (état, {payload}: PayloadAction ) {
      const {id} = charge utile;
      const diaryIndex = state.findIndex ((diary) => diary.id === id);
      if (diaryIndex! == -1) {
        state.splice (diaryIndex, 1, charge utile);
      }
    },
  },
});

export const {addDiary, updateDiary} = diaries.actions;
exporter les journaux par défaut.reducer;

La propriété «diaries» de notre état est un tableau contenant les journaux de l'utilisateur, donc nos fonctions de réduction ici fonctionnent toutes sur l'objet d'état qu'elles reçoivent en utilisant des méthodes de tableau. Remarquez ici que nous écrivons du code «mutatif» normal lorsque nous travaillons sur l'état. Cela est possible car les fonctions de réduction que nous créons à l’aide de la méthode createSlice () sont encapsulées avec la méthode produire () d’Immer. Cela aboutit à ce qu'Immer renvoie un résultat correct et immuablement mis à jour pour notre état, que nous écrivions du code mutatif.

Ensuite, créez un fichier nommé entriesSlice.ts sous src / features / entry :

 # ~ / diaries-app / src / features
entrée mkdir
entrée cd
entrées tactilesSlice.ts

Ouvrez le fichier et ajoutez le code suivant:

 import {createSlice, PayloadAction} de '@ reduxjs / toolkit';
import {Entrée} de '../../interfaces/entry.interface';

entrées const = createSlice ({
  nom: 'entrées',
  initialState: [] comme entrée [],
  réducteurs: {
    setEntries (état, {charge utile}: PayloadAction ) {
      return (état = charge utile! = null? charge utile: []);
    },
    updateEntry (état, {payload}: PayloadAction ) {
      const {id} = charge utile;
      index const = state.findIndex ((e) => e.id === id);
      if (index! == -1) {
        state.splice (index, 1, charge utile);
      }
    },
  },
});

export const {setEntries, updateEntry} = entries.actions;
exporter les entrées par défaut.reducer;

Les fonctions de réduction ici ont une logique similaire aux fonctions de réduction de la tranche précédente. La propriété entries est également un tableau, mais elle ne contient que les entrées d'un seul journal. Dans notre application, ce sera le journal actuellement dans le focus de l'utilisateur.

Enfin, créez un fichier nommé editorSlice.ts dans src / features / entry et ajoutez ce qui suit:

 import {createSlice, PayloadAction} from '@ reduxjs / toolkit';
import {Entrée} de '../../interfaces/entry.interface';

interface EditorState {
  canEdit: booléen;
  currentEditing: Entrée | nul;
  activeDiaryId: chaîne | nul;
}

const initialState: EditorState = {
  canEdit: faux,
  currentEditing: null,
  activeDiaryId: null,
};

éditeur const = createSlice ({
  nom: 'éditeur',
  Etat initial,
  réducteurs: {
    setCanEdit (état, {charge utile}: PayloadAction ) {
      state.canEdit = charge utile! = null? charge utile:! state.canEdit;
    },
    setCurrentlyEditing (état, {charge utile}: PayloadAction ) {
      state.currentlyEditing = charge utile;
    },
    setActiveDiaryId (état, {payload}: PayloadAction ) {
      state.activeDiaryId = charge utile;
    },
  },
});

export const {setCanEdit, setCurrentlyEditing, setActiveDiaryId} = editor.actions;
export par défaut editor.reducer;

Ici, nous avons une part de la propriété editor dans l'état. Nous utiliserons les propriétés de cet objet pour vérifier si l'utilisateur veut passer en mode édition, à quel journal appartient l'entrée éditée et à quelle entrée va être éditée.

Pour tout mettre ensemble, créez un fichier nommé rootReducer.ts dans le répertoire src avec le contenu suivant:

 import {combineReducers} de '@ reduxjs / toolkit';
import authReducer de './features/auth/authSlice';
importer userReducer depuis './features/auth/userSlice';
import diariesReducer de './features/diary/diariesSlice';
importer des entréesReducer de './features/entry/entriesSlice';
import editorReducer de './features/entry/editorSlice';

const rootReducer = combineReducers ({
  auth: authReducer,
  agendas: agendas
  entrées: entréesReducer,
  utilisateur: userReducer,
  éditeur: editorReducer,
});

type d'exportation RootState = ReturnType ;
exporter rootReducer par défaut;

Dans ce fichier, nous avons combiné nos réducteurs de tranche en un seul réducteur de racine avec la fonction combineReducers () . We’ve also exported the RootState type, which will be useful later when we’re selecting values from the store. We can now use the root reducer (the default export of this file) to set up our store.

Create a file named store.ts with the following contents:

import { configureStore } from '@reduxjs/toolkit';
import rootReducer from './rootReducer';
import { useDispatch } from 'react-redux';

const store = configureStore({
  reducer: rootReducer,
});

type AppDispatch = typeof store.dispatch;
export const useAppDispatch = () => useDispatch();
export default store;

With this, we’ve created a store using the configureStore() export from Redux toolkit. We’ve also exported an hook called useAppDispatch() which merely returns a typed useDispatch() hook.

Next, update the imports in index.tsx to look like the following:

import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './app/App';
import * as serviceWorker from './serviceWorker';
import { setupServer } from './services/mirage/server';
import { Provider } from 'react-redux';
import store from './store';
// ...

Finally, make the store available to the app’s components by wrapping (the top-level component) with :

ReactDOM.render(
  
    
      
    
  ,
  document.getElementById('root')
);

Now, if you start your app and you navigate to http://localhost:3000 with the Redux Dev Tools extension enabled, you should see the following in your app’s state:

Initial State in Redux Dev Tools Extension. (Large preview)

Great work so far, but we’re not quite finished yet. In the next section, we will design the app’s User Interface and add functionality using the store we’ve just created.

Designing The Application User Interface

To see Redux in action, we are going to build a demo app. In this section, we will connect our components to the store we’ve created and learn to dispatch actions and modify the state using reducer functions. We will also learn how to read values from the store. Here’s what our Redux-powered application will look like.

Home page showing an authenticated user’s diaries. (Large preview)
Screenshots of final app. (Large preview)

Setting up the Authentication Feature

To get started, move App.tsx and its related files from the src directory to its own directory like this:

# ~/diaries-app/src
mkdir app
mv App.tsx App.test.tsx app

You can delete the App.css and logo.svg files as we won’t be needing them.

Next, open the App.tsx file and replace its contents with the following:

import React, { FC, lazy, Suspense } from 'react';
import { BrowserRouter as Router, Switch, Route } from 'react-router-dom';
import { useSelector } from 'react-redux';
import { RootState } from '../rootReducer';

const Auth = lazy(() => import('../features/auth/Auth'));
const Home = lazy(() => import('../features/home/Home'));

const App: FC = () => {
  const isLoggedIn = useSelector(
    (state: RootState) => state.auth.isAuthenticated
  );
  return (
    
      
        
          <Suspense fallback={

Loading...

}> {isLoggedIn ? : }
); }; export default App;

Here we have set up our app to render an component if the user is unauthenticated, or otherwise render a component. We haven’t created either of these components yet, so let’s fix that. Create a file named Auth.tsx under src/features/auth and add the following contents to the file:

import React, { FC, useState } from 'react';
import { useForm } from 'react-hook-form';
import { User } from '../../interfaces/user.interface';
import * as Yup from 'yup';
import http from '../../services/api';
import { saveToken, setAuthState } from './authSlice';
import { setUser } from './userSlice';
import { AuthResponse } from '../../services/mirage/routes/user';
import { useAppDispatch } from '../../store';

const schema = Yup.object().shape({
  username: Yup.string()
    .required('What? No username?')
    .max(16, 'Username cannot be longer than 16 characters'),
  password: Yup.string().required('Without a password, "None shall pass!"'),
  email: Yup.string().email('Please provide a valid email address (abc@xy.z)'),
});

const Auth: FC = () => {
  const { handleSubmit, register, errors } = useForm({
    validationSchema: schema,
  });
  const [isLogin, setIsLogin] = useState(true);
  const [loading, setLoading] = useState(false);
  const dispatch = useAppDispatch();

  const submitForm = (data: User) => {
    const path = isLogin ? '/auth/login' : '/auth/signup';
    http
      .post(path, data)
      .then((res) => {
        if (res) {
          const { user, token } = res;
          dispatch(saveToken(token));
          dispatch(setUser(user));
          dispatch(setAuthState(true));
        }
      })
      .catch((error) => {
        console.log(error);
      })
      .finally(() => {
        setLoading(false);
      });
  };

  return (
    
{errors && errors.username && (

{errors.username.message}

)}
{errors && errors.password && (

{errors.password.message}

)}
{!isLogin && (
{errors && errors.email && (

{errors.email.message}

)}
)}

setIsLogin(!isLogin)} style={{ cursor: 'pointer', opacity: 0.7 }} > {isLogin ? 'No account? Create one' : 'Already have an account?'}

); }; export default Auth;

In this component, we have set up a form for users to log in, or to create an account. Our form fields are validated using Yup and, on successfully authenticating a user, we use our useAppDispatch hook to dispatch the relevant actions. You can see the dispatched actions and the changes made to your state in the Redux DevTools Extension:

Dispatched Actions with Changes Tracked in Redux Dev Tools Extensions. (Large preview)

Finally, create a file named Home.tsx under src/features/home and add the following code to the file:

import React, { FC } from 'react';

const Home: FC = () => {
  return (
    

Welcome user!

); }; export default Home;

For now, we are just displaying some text to the authenticated user. As we build the rest of our application, we will be updating this file.

Setting up the Editor

The next component we are going to build is the editor. Though basic, we will enable support for rendering markdown content using the markdown-to-jsx library we installed earlier.

First, create a file named Editor.tsx in the src/features/entry directory. Then, add the following code to the file:

import React, { FC, useState, useEffect } from 'react';
import { useSelector } from 'react-redux';
import { RootState } from '../../rootReducer';
import Markdown from 'markdown-to-jsx';
import http from '../../services/api';
import { Entry } from '../../interfaces/entry.interface';
import { Diary } from '../../interfaces/diary.interface';
import { setCurrentlyEditing, setCanEdit } from './editorSlice';
import { updateDiary } from '../diary/diariesSlice';
import { updateEntry } from './entriesSlice';
import { showAlert } from '../../util';
import { useAppDispatch } from '../../store';

const Editor: FC = () => {
  const { currentlyEditing: entry, canEdit, activeDiaryId } = useSelector(
    (state: RootState) => state.editor
  );
  const [editedEntry, updateEditedEntry] = useState(entry);
  const dispatch = useAppDispatch();

  const saveEntry = async () => {
    if (activeDiaryId == null) {
      return showAlert('Please select a diary.', 'warning');
    }
    if (entry == null) {
      http
        .post(
          `/diaries/entry/${activeDiaryId}`,
          editedEntry
        )
        .then((data) => {
          if (data != null) {
            const { diary, entry: _entry } = data;
            dispatch(setCurrentlyEditing(_entry));
            dispatch(updateDiary(diary));
          }
        });
    } else {
      http
        .put(`diaries/entry/${entry.id}`, editedEntry)
        .then((_entry) => {
          if (_entry != null) {
            dispatch(setCurrentlyEditing(_entry));
            dispatch(updateEntry(_entry));
          }
        });
    }
    dispatch(setCanEdit(false));
  };

  useEffect(() => {
    updateEditedEntry(entry);
  }, [entry]);

  return (
    
{entry && !canEdit ? (

{entry.title} { e.preventDefault(); if (entry != null) { dispatch(setCanEdit(true)); } }} style={{ marginLeft: '0.4em' }} > (Edit)

) : ( { if (editedEntry) { updateEditedEntry({ ...editedEntry, title: e.target.value, }); } else { updateEditedEntry({ title: e.target.value, content: '', }); } }} /> )}
{entry && !canEdit ? ( {entry.content} ) : ( <>