Catégories
Astuces et Design

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

A propos de l'auteur

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

Redux est une bibliothèque de gestion d'état robuste 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 peut cependant être un 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 la gestion de l'état. En tant que tel, l'importance de savoir comment cela fonctionne ne peut être surestimée.

Ce guide guidera le lecteur tout au long de 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 tous ceux qui souhaitent combler les lacunes de leurs connaissances sur Redux.

Présentation de Redux

Redux est une bibliothèque qui vise à résoudre le problème de la gestion d'état dans les applications JavaScript en imposant des restrictions sur la manière et le moment où les mises à jour d'état peuvent se produire. Ces restrictions sont formées des «trois principes» de Redux qui sont:

  • Source unique de vérité
    Toutes vos applications state se tient dans un Redux store. Cet état peut être représenté visuellement sous la forme d'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 j'aimerais insérer / mettre à jour / supprimer».

  • Les modifications sont effectuées avec des fonctions pures
    Pour modifier 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 s'appelle un reducer, 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 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 vient pas 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 une logique de mise à jour immuable «mutative», sous le capot.

Lecture recommandée: 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.

Construire Diaries.app

Comme indiqué dans la section précédente, nous allons examiner 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 créer notre application à l'aide de 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

Commençons notre projet. Tout d'abord, démarrez une nouvelle application React à l'aide de create-react-app:

Utilisation de 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. Naviguez dans votre répertoire de 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 créer, mais qui ne sont pas nécessaires pour fonctionner avec Redux.

La dernière commande installe Mirage et saisit les déclarations pour les packages 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 selon 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 désormais 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 forme d'un objet.

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

cd src && mkdir interfaces

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

touch entry.interface.ts
touch diary.interface.ts
touch 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 seul endroit.

Ouvert entry.interface.ts et ajoutez le code suivant pour configurer le Entry interface:

export interface Entry {
  id?: string;
  title: string;
  content: string;
  createdAt?: string;
  updatedAt?: string;
  diaryId?: string;
}

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 diaryId propriété plus tard.

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

export interface Diary {
  id?: string;
  title: string;
  type: 'private' | 'public';
  createdAt?: string;
  updatedAt?: string;
  userId?: string;
  entryIds: string() | null;
}

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

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

export interface User {
  id?: string;
  username: string;
  email: string;
  password?: string;
  diaryIds: string() | null;
}

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

Configuration de la simulation d'API avec MirageJS

Étant donné que ce tutoriel est axé sur Redux, nous n'entrerons 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 src répertoire et créez un fichier nommé server.ts en exécutant les commandes suivantes:

mkdir -p services/mirage
cd services/mirage

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

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

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

export const handleErrors = (error: any, message = 'An error ocurred') => {
  return new Response(400, undefined, {
    data: {
      message,
      isError: true,
    },
  });
};

export const setupServer = (env?: string): Server => {
  return new Server({
    environment: env ?? 'development',

    models: {
      entry: Model.extend({
        diary: belongsTo(),
      }),
      diary: Model.extend({
        entry: hasMany(),
        user: belongsTo(),
      }),
      user: Model.extend({
        diary: hasMany(),
      }),
    },

    factories: {
      user: Factory.extend({
        username: 'test',
        password: 'password',
        email: 'test@email.com',
      }),
    },

    seeds: (server): any => {
      server.create('user');
    },

    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. le setupServer() function prend un argument facultatif qui peut être utilisé pour changer l'environnement du serveur. Vous pouvez l'utiliser pour configurer Mirage pour un test ultérieur.

Nous avons également défini trois modèles dans le serveur models propriété: User, Diary et Entry. Rappelez-vous que plus tôt, nous avons mis en place le Entry interface avec une propriété nommée diaryId. Cette valeur sera automatiquement réglée sur id l'entrée est en cours d'enregistrement. Mirage utilise cette propriété pour établir une relation entre un Entry et un Diary. La même chose se produit également lorsqu'un utilisateur crée un nouveau journal: userId est automatiquement défini sur l'identifiant 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 en commençant par https://diaries.app. Notez que nous n'avons encore configuré aucun gestionnaire d'itinéraire. Allons-y et créons-en quelques-uns.

Assurez-vous que vous êtes dans le src / services / mirage répertoire, puis créez un nouveau répertoire nommé itinéraires en utilisant la commande suivante:

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

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

cd routes
touch user.ts

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

import { Response, Request } from 'miragejs';
import { handleErrors } from '../server';
import { User } from '../../../interfaces/user.interface';
import { randomBytes } from 'crypto';

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

export interface AuthResponse {
  token: string;
  user: User;
}

const login = (schema: any, req: Request): AuthResponse | Response => {
  const { username, password } = JSON.parse(req.requestBody);
  const user = schema.users.findBy({ username });
  if (!user) {
    return handleErrors(null, 'No user with that username exists');
  }
  if (password !== user.password) {
    return handleErrors(null, 'Password is incorrect');
  }
  const token = generateToken();
  return {
    user: user.attrs as User,
    token,
  };
};

const signup = (schema: any, req: Request): AuthResponse | Response => {
  const data = JSON.parse(req.requestBody);
  const exUser = schema.users.findBy({ username: data.username });
  if (exUser) {
    return handleErrors(null, 'A user with that username already exists.');
  }
  const user = schema.users.create(data);
  const token = generateToken();
  return {
    user: user.attrs as User,
    token,
  };
};

export default {
  login,
  signup,
};

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

Ensuite, ajoutons des méthodes pour travailler avec les agendas et les entrées d'agenda. Créez un fichier nommé diary.ts dans votre itinéraires annuaire:

touch diary.ts

Mettez à jour le fichier avec les méthodes suivantes pour travailler avec Diary Ressources:

export const create = (
  schema: any,
  req: Request
): { user: User; diary: Diary } | Response => {
  try {
    const { title, type, userId } = JSON.parse(req.requestBody) as Partial<
      Diary
    >;
    const exUser = schema.users.findBy({ id: userId });
    if (!exUser) {
      return handleErrors(null, 'No such user exists.');
    }
    const now = dayjs().format();
    const diary = exUser.createDiary({
      title,
      type,
      createdAt: now,
      updatedAt: now,
    });
    return {
      user: {
        ...exUser.attrs,
      },
      diary: diary.attrs,
    };
  } catch (error) {
    return handleErrors(error, 'Failed to create Diary.');
  }
};

export const updateDiary = (schema: any, req: Request): Diary | Response => {
  try {
    const diary = schema.diaries.find(req.params.id);
    const data = JSON.parse(req.requestBody) as Partial;
    const now = dayjs().format();
    diary.update({
      ...data,
      updatedAt: now,
    });
    return diary.attrs as Diary;
  } catch (error) {
    return handleErrors(error, 'Failed to update Diary.');
  }
};

export const getDiaries = (schema: any, req: Request): Diary() | Response => {
  try {
    const user = schema.users.find(req.params.id);
    return user.diary as Diary();
  } catch (error) {
    return handleErrors(error, 'Could not get user diaries.');
  }
};

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

export const addEntry = (
  schema: any,
  req: Request
): { diary: Diary; entry: Entry } | Response => {
  try {
    const diary = schema.diaries.find(req.params.id);
    const { title, content } = JSON.parse(req.requestBody) as Partial;
    const now = dayjs().format();
    const entry = diary.createEntry({
      title,
      content,
      createdAt: now,
      updatedAt: now,
    });
    diary.update({
      ...diary.attrs,
      updatedAt: now,
    });
    return {
      diary: diary.attrs,
      entry: entry.attrs,
    };
  } catch (error) {
    return handleErrors(error, 'Failed to save entry.');
  }
};

export const getEntries = (
  schema: any,
  req: Request
): { entries: Entry() } | Response => {
  try {
    const diary = schema.diaries.find(req.params.id);
    return diary.entry;
  } catch (error) {
    return handleErrors(error, 'Failed to get Diary entries.');
  }
};

export const updateEntry = (schema: any, req: Request): Entry | Response => {
  try {
    const entry = schema.entries.find(req.params.id);
    const data = JSON.parse(req.requestBody) as Partial;
    const now = dayjs().format();
    entry.update({
      ...data,
      updatedAt: now,
    });
    return entry.attrs as Entry;
  } catch (error) {
    return handleErrors(error, 'Failed to update entry.');
  }
};

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

import { Response, Request } from 'miragejs';
import { handleErrors } from '../server';
import { Diary } from '../../../interfaces/diary.interface';
import { Entry } from '../../../interfaces/entry.interface';
import dayjs from 'dayjs';
import { User } from '../../../interfaces/user.interface';

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

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

Nous avons également un getDiaries méthode qui récupère les agendas d'un utilisateur et un getEntries méthodes qui récupèrent 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. Ouvert server.ts pour inclure les fichiers:

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

import user from './routes/user';
import * as diary from './routes/diary';

Ensuite, mettez à jour le serveur route property avec les routes que nous voulons gérer:

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

      this.get('/diaries/entries/: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('/diaries/entry/:id', diary.addEntry);

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

Avec ce changement, lorsqu'une demande réseau de notre application correspond à l'un des gestionnaires d'itinéraire, Mirage intercepte la demande et appelle les fonctions de gestionnaire d'itinéraire respectives.

Ensuite, nous allons faire en sorte que notre application connaisse le serveur. Ouvert src / index.tsx et importez le setupServer() méthode:

import { setupServer } from './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 de développement.

Une dernière chose que nous devons faire avant de passer aux bits Redux est de configurer une instance Axios personnalisée à 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 } from 'axios';
import { showAlert } from '../util';

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

http.defaults.headers.post('Content-Type') = 'application/json';

http.interceptors.response.use(
  async (response: AxiosResponse): Promise => {
    if (response.status >= 200 && response.status < 300) {
      return response.data;
    }
  },
  (error: AxiosError) => {
    const { response, request }: {
      response?: AxiosResponse;
      request?: XMLHttpRequest;
    } = error;
    if (response) {
      if (response.status >= 400 && response.status < 500) {
        showAlert(response.data?.data?.message, 'error');
        return null;
      }
    } else if (request) {
      showAlert('Request failed. Please try again.', 'error');
      return null;
    }
    return Promise.reject(error);
  }
);

export default 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 réussite 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 = 'Something happened.', alertType?: SweetAlertIcon): void => {
  Swal.fire({
    titleText,
    position: 'top-end',
    timer: 3000,
    timerProgressBar: true,
    toast: true,
    showConfirmButton: false,
    showCancelButton: true,
    cancelButtonText: 'Dismiss',
    icon: alertType,
    showClass: {
      popup: 'swal2-noanimation',
      backdrop: 'swal2-noanimation',
    },
    hideClass: {
      popup: '',
      backdrop: '',
    },
  });
};

Ce fichier exporte une fonction qui affiche un toast chaque fois qu'il est appelé. La fonction accepte des paramètres pour vous permettre de définir le message et le type de toast. Par exemple, nous montrons 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 à partir de 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 à l'aide de la boîte à outils Redux.

Configurer un magasin 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 sur le Redux createStore() fonction qui aide à simplifier votre code. Il utilise createStore() en interne pour configurer votre boutique avec des outils de développement utiles:

export const store = configureStore({
  reducer: rootReducer, // a single reducer function or an object of slice reducers
});

le createSlice() La fonction 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 seule fonction de réduction, qui peut être transmise à Redux combineReducers() fonctionne comme un «réducteur de tranche».

N'oubliez pas que l'état est un seul arbre et qu'un seul réducteur de racine gère les modifications apportées à 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 jointes 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émarrez votre application à partir d'un état enregistré à l'aide du preloadedState option. Lorsque vous fournissez le middleware option, vous devez définir tout 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),
});

Passons maintenant à la configuration de notre boutique. Nous adopterons une approche de type «canard» 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: {
    token: string | null;
    isAuthenticated: boolean;
  };
  diaries: Diary();
  entries: Entry();
  user: User | null;
  editor: {
    canEdit: boolean;
    currentlyEditing: Entry | null;
    activeDiaryId: string | null;
  };
}

Pour commencer, créez un nouveau répertoire nommé fonctionnalités sous votre src annuaire:

# ~/diaries-app/src
mkdir features

Ensuite, cd dans les fonctionnalités et créez des répertoires nommés auth, journal intime et entrée:

cd features
mkdir auth diary entry

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

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

Ouvrez le fichier et collez-y les éléments suivants:

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

interface AuthState {
  token: string | null;
  isAuthenticated: boolean;
}

const initialState: AuthState = {
  token: null,
  isAuthenticated: false,
};

const auth = createSlice({
  name: 'auth',
  initialState,
  reducers: {
    saveToken(state, { payload }: PayloadAction) {
      if (payload) {
        state.token = payload;
      }
    },
    clearToken(state) {
      state.token = null;
    },
    setAuthState(state, { payload }: PayloadAction) {
      state.isAuthenticated = payload;
    },
  },
});

export const { saveToken, clearToken, setAuthState } = auth.actions;
export default auth.reducer;

Dans ce fichier, nous créons une tranche pour le auth propriété de l'état de notre application à l'aide de createSlice() fonction introduite plus tôt. le reducers property contient une carte des fonctions de réduction pour la mise à jour des 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'état de l'application que nous avons vu précédemment. Commencez par créer un fichier nommé userSlice.ts dans le répertoire auth et ajoutez-y le code suivant:

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

const user = createSlice({
  name: 'user',
  initialState: null as User | null,
  reducers: {
    setUser(state, { payload }: PayloadAction) {
      return state = (payload != null) ? payload : null;
    },
  },
});

export const { setUser } = user.actions;
export default user.reducer;

Cela crée un réducteur de tranche pour le user propriété dans notre boutique de l'application. le setUser La fonction reducer 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é agendasSlice.ts sous src / fonctionnalités / agenda:

# ~/diaries-app/src/features
cd diary
touch diariesSlice.ts

Ajoutez le code suivant au fichier:

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

const diaries = createSlice({
  name: 'diaries',
  initialState: () as Diary(),
  reducers: {
    addDiary(state, { payload }: PayloadAction) {
      const diariesToSave = payload.filter((diary) => {
        return state.findIndex((item) => item.id === diary.id) === -1;
      });
      state.push(...diariesToSave);
    },
    updateDiary(state, { payload }: PayloadAction) {
      const { id } = payload;
      const diaryIndex = state.findIndex((diary) => diary.id === id);
      if (diaryIndex !== -1) {
        state.splice(diaryIndex, 1, payload);
      }
    },
  },
});

export const { addDiary, updateDiary } = diaries.actions;
export default diaries.reducer;

La propriété "diaries" de notre état est un tableau contenant les agendas 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. Ceci est possible car les fonctions de réduction que nous créons à l'aide de createSlice() méthode sont enveloppés avec Immer's produce() méthode. 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 / fonctionnalités / entrée:

# ~/diaries-app/src/features
mkdir entry
cd entry
touch entriesSlice.ts

Ouvrez le fichier et ajoutez le code suivant:

import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { Entry } from '../../interfaces/entry.interface';

const entries = createSlice({
  name: 'entries',
  initialState: () as Entry(),
  reducers: {
    setEntries(state, { payload }: PayloadAction) {
      return (state = payload != null ? payload : ());
    },
    updateEntry(state, { payload }: PayloadAction) {
      const { id } = payload;
      const index = state.findIndex((e) => e.id === id);
      if (index !== -1) {
        state.splice(index, 1, payload);
      }
    },
  },
});

export const { setEntries, updateEntry } = entries.actions;
export default entries.reducer;

Les fonctions de réduction ici ont une logique similaire aux fonctions de réduction de la tranche précédente. le entries property est également un tableau, mais il ne contient que les entrées d'un seul journal. Dans notre application, ce sera le journal qui est actuellement au centre de l'attention de l'utilisateur.

Enfin, créez un fichier nommé editorSlice.ts dans src / features / entry et ajoutez-y les éléments suivants:

import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { Entry } from '../../interfaces/entry.interface';

interface EditorState {
  canEdit: boolean;
  currentlyEditing: Entry | null;
  activeDiaryId: string | null;
}

const initialState: EditorState = {
  canEdit: false,
  currentlyEditing: null,
  activeDiaryId: null,
};

const editor = createSlice({
  name: 'editor',
  initialState,
  reducers: {
    setCanEdit(state, { payload }: PayloadAction) {
      state.canEdit = payload != null ? payload : !state.canEdit;
    },
    setCurrentlyEditing(state, { payload }: PayloadAction) {
      state.currentlyEditing = payload;
    },
    setActiveDiaryId(state, { payload }: PayloadAction) {
      state.activeDiaryId = payload;
    },
  },
});

export const { setCanEdit, setCurrentlyEditing, setActiveDiaryId } = editor.actions;
export default editor.reducer;

Ici, nous avons une tranche pour le editor propriété en état. Nous utiliserons les propriétés de cet objet pour vérifier si l'utilisateur souhaite passer en mode d'édition, à quel journal appartient l'entrée modifiée et à quelle entrée sera modifiée.

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

import { combineReducers } from '@reduxjs/toolkit';
import authReducer from './features/auth/authSlice';
import userReducer from './features/auth/userSlice';
import diariesReducer from './features/diary/diariesSlice';
import entriesReducer from './features/entry/entriesSlice';
import editorReducer from './features/entry/editorSlice';

const rootReducer = combineReducers({
  auth: authReducer,
  diaries: diariesReducer,
  entries: entriesReducer,
  user: userReducer,
  editor: editorReducer,
});

export type RootState = ReturnType;
export default rootReducer;

Dans ce fichier, nous avons combiné nos réducteurs de tranche en un seul réducteur de racine avec le combineReducers() fonction. Nous avons également exporté le RootState type, qui sera utile plus tard lorsque nous sélectionnerons des valeurs dans le magasin. Nous pouvons maintenant utiliser le réducteur de racine (l'exportation par défaut de ce fichier) pour configurer notre magasin.

Créez un fichier nommé store.ts avec le contenu suivant:

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;

Avec cela, nous avons créé une boutique en utilisant le configureStore() exportation depuis la boîte à outils Redux. Nous avons également exporté un hook appelé useAppDispatch() qui renvoie simplement un typé useDispatch() crochet.

Ensuite, mettez à jour les importations dans index.tsx pour qu'elles ressemblent à ce qui suit:

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';
// ...

Enfin, faites le store disponible pour les composants de l'application en enveloppant (le composant de premier niveau) avec :

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

Maintenant, si vous démarrez votre application et que vous accédez à http: // localhost: 3000 avec l'extension Redux Dev Tools activée, vous devriez voir ce qui suit dans l'état de votre application:

État initial dans l'extension Redux Dev Tools. (Grand aperçu)

Excellent travail jusqu'à présent, mais nous n'avons pas encore tout à fait terminé. Dans la section suivante, nous allons concevoir l'interface utilisateur de l'application et ajouter des fonctionnalités à l'aide du magasin que nous venons de créer.

Conception de l'interface utilisateur de l'application

Pour voir Redux en action, nous allons créer une application de démonstration. Dans cette section, nous allons connecter nos composants au magasin que nous avons créé et apprendre à distribuer des actions et à modifier l'état à l'aide des fonctions de réduction. Nous apprendrons également à lire les valeurs du magasin. Voici à quoi ressemblera notre application basée sur Redux.

Page d'accueil affichant les agendas d'un utilisateur authentifié. (Grand aperçu)
Captures d'écran de l'application finale. (Grand aperçu)

Configuration de la fonction d'authentification

Pour commencer, bougez App.tsx et ses fichiers associés du src répertoire dans son propre répertoire comme ceci:

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

Vous pouvez supprimer les fichiers App.css et logo.svg car nous n'en aurons pas besoin.

Ensuite, ouvrez le fichier App.tsx et remplacez son contenu par ce qui suit:

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 (
    
      
        
          Loading...

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

Ici, nous avons configuré notre application pour rendre un composant si l'utilisateur n'est pas authentifié, ou restituer une composant. Nous n'avons encore créé aucun de ces composants, alors corrigeons ce problème. Créez un fichier nommé Auth.tsx sous src / features / auth et ajoutez le contenu suivant au fichier:

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;

Dans ce composant, nous avons mis en place un formulaire permettant aux utilisateurs de se connecter ou de créer un compte. Nos champs de formulaire sont validés à l'aide de Yup et, après authentification réussie d'un utilisateur, nous utilisons notre useAppDispatch hook pour envoyer les actions pertinentes. Vous pouvez voir les actions distribuées et les modifications apportées à votre état dans l'extension Redux DevTools:

Actions distribuées avec les modifications suivies dans les extensions Redux Dev Tools. (Grand aperçu)

Enfin, créez un fichier nommé Accueil.tsx sous src / features / home et ajoutez le code suivant au fichier:

import React, { FC } from 'react';

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

Welcome user!

); }; export default Home;

Pour l'instant, nous affichons simplement du texte à l'utilisateur authentifié. Au fur et à mesure que nous construirons le reste de notre application, nous mettrons à jour ce fichier.

Configuration de l'éditeur

Le prochain composant que nous allons construire est l'éditeur. Bien que basique, nous activerons la prise en charge du rendu du contenu de démarque à l'aide de markdown-to-jsx bibliothèque que nous avons installée plus tôt.

Commencez par créer un fichier nommé Editor.tsx dans le répertoire src / features / entry. Ensuite, ajoutez le code suivant au fichier:

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