Comment créer une application Todo avec React, TypeScript, NodeJS et MongoDB

Jul 21, 2020☕ ☕ 19 min Follow me on Twitter

Subscribe to receive the free weekly article

Dans ce didacticiel, nous allons utiliser TypeScript des deux côtés (serveur et client) pour créer une application Todo à partir de zéro avec React, NodeJS, Express et MongoDB.

Alors, commençons par planifier l'API.

Allons-y

API avec NodeJS, Express, MongoDB et TypeScript

Configuration

Si vous débutez dans ce domaine, vous pouvez commencer par Un guide pratique de TypeScript ou Comment créer une API à partir de zéro avec Node JS, Express et MongoDB pour tirer meilleur parti de ce tutoriel; sinon, commençons .

Pour créer une nouvelle application NodeJS, vous devez exécuter cette commande sur le terminal.

  yarn init

Il vous posera quelques questions, puis initialisera l'application. Vous pouvez l'ignorer en ajoutant un indicateur -y à la commande.

Ensuite, structurez le projet comme suit.

├── dist
├── node_modules
├── src
   ├── app.ts
   ├── controllers
   |  └── todos
   |     └── index.ts
   ├── models
   |  └── todo.ts
   ├── routes
   |  └── index.ts
   └── types
      └── todo.ts
├── nodemon.json
├── package.json
├── tsconfig.json

Comme vous pouvez le voir, cette structure de fichier est relativement simple. Le répertoire dist servira de dossier de sortie une fois le code compilé en JavaScript brut. Nous avons également un fichier app.ts qui est le point d'entrée du serveur. Les contrôleurs, types et routes sont également dans leurs noms de dossier respectifs.

Maintenant, nous devons configurer le fichier tsconfig.json pour aider le compilateur à suivre nos préférences.

  • tsconfig.json
{
  "compilerOptions": {
    "target": "es6",
    "module": "commonjs",
    "outDir": "dist/js",
    "rootDir": "src",
    "strict": true,
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true
  },
  "include": ["src/**/*"],
  "exclude": ["src/types/*.ts", "node_modules", ".vscode"]
}

Ici, nous avons quatre propriétés principales à souligner:

  • outDir: Il dit au compilateur de mettre le code compilé dans le dossier dist/js.
  • rootDir: Il informe TypeScript de compiler tous les fichiers .ts situés dans le dossier src.
  • include: Il indique au compilateur d'inclure les fichiers qui se trouvent dans le répertoire et le sous-répertoire src.
  • exclude: Cela exclura les fichiers ou dossiers passés dans le tableau lors de la compilation.

Nous pouvons maintenant installer les dépendances pour activer TypeScript dans le projet. Parce que par défaut, cette application utilisera JavaScript.

Il existe deux façons d'utiliser TypeScript dans une application NodeJS. Soit localement dans le projet, ou globalement dans notre machine. Je vais opter pour ce dernier par préférence personnelle, mais vous pouvez vous en tenir à la méthode locale si vous le souhaitez aussi.

Maintenant, exécutons la commande suivante sur le terminal pour installer TypeScript.

  yarn add typescript -g

Cet indicateur g permet d'installer TypeScript globalement et donc de le rendre accessible de n'importe où sur l'ordinateur.

Ensuite, ajoutons quelques dépendances afin d'utiliser Express et MongoDB.

  yarn add express cors mongoose

Nous devons également installer leurs types en tant que dépendances de développement pour aider le compilateur TypeScript à comprendre les packages.

  yarn add -D @types/node @types/express @types/mongoose @types/cors

Désormais, TypeScript ne vous hurlera plus dessus; il utilisera ces types pour définir les bibliothèques que nous venons d'installer.

Nous devons également ajouter d'autres dépendances pour pouvoir compiler le code TypeScript et démarrer le serveur simultanément.

  yarn add -D concurrently nodemon

Avec cela en place, nous pouvons maintenant mettre à jour le fichier package.json avec les scripts nécessaires pour démarrer le serveur.

  • package.json
  "scripts": {
    "build": "tsc",
    "start": "concurrently \"tsc -w\" \"nodemon dist/js/app.js\""
  }

concurrently aidera à compiler le code TypeScript, à surveiller les changements et à démarrer le serveur simultanément. Cela dit, nous pouvons maintenant lancer le serveur; cependant, nous n'avons pas encore créé quelque chose de significatif à cet égard. Alors, corrigeons cela dans la section suivante.

Créer un type Todo

  • types/todo.ts
import { Document } from "mongoose"

export interface ITodo extends Document {
  name: string
  description: string
  status: boolean
}

Ici, nous avons une interface Todo qui étend le type Document fourni par mongoose. Il sera utiliser plus tard pour interagir avec MongoDB. Cela dit, nous pouvons maintenant définir à quoi doit ressembler un modèle Todo.

Créer un modèle Todo

  • models/todo.ts
import { ITodo } from "./../types/todo"
import { model, Schema } from "mongoose"

const todoSchema: Schema = new Schema(
  {
    name: {
      type: String,
      required: true,
    },

    description: {
      type: String,
      required: true,
    },

    status: {
      type: Boolean,
      required: true,
    },
  },
  { timestamps: true }
)

export default model<ITodo>("Todo", todoSchema)

Comme vous pouvez le voir ici, nous commençons par importer l'interface ITodo et quelques utilitaires de mongoose. Ce dernier permet de définir le schéma Todo et de transmettre également ITodo comme type au model avant de l'exporter.

Avec cela, nous pouvons désormais utiliser le modèle Todo dans d'autres fichiers pour interagir avec la base de données.

Créer des contrôleurs d'API

Récupérer, ajouter, mettre à jour et supprimer un Todo

  • controllers/todos/index.ts
import { Response, Request } from "express"
import { ITodo } from "./../../types/todo"
import Todo from "../../models/todo"

const getTodos = async (req: Request, res: Response): Promise<void> => {
  try {
    const todos: ITodo[] = await Todo.find()
    res.status(200).json({ todos })
  } catch (error) {
    throw error
  }
}

Ici, nous devons d'abord importer certains types de express car je veux saisir les valeurs explicitement. Si vous le souhaitez aussi, vous pouvez laisser TypeScript le déduire pour vous.

Ensuite, nous utilisons la fonction getTodos() pour récupérer des données. Il reçoit les paramètres req et res et renvoie une promesse.

Et avec l'aide du modèle Todo créé précédemment, nous pouvons maintenant obtenir des données de MongoDB et renvoyer une réponse avec le tableau de todos.

  • controllers/todos/index.ts
const addTodo = async (req: Request, res: Response): Promise<void> => {
  try {
    const body = req.body as Pick<ITodo, "name" | "description" | "status">

    const todo: ITodo = new Todo({
      name: body.name,
      description: body.description,
      status: body.status,
    })

    const newTodo: ITodo = await todo.save()
    const allTodos: ITodo[] = await Todo.find()

    res
      .status(201)
      .json({ message: "Todo added", todo: newTodo, todos: allTodos })
  } catch (error) {
    throw error
  }
}

Comme vous pouvez le voir, la fonction addTodo() reçoit l'objet body qui contient les données saisies par l'utilisateur.

Ensuite, j'utilise le typage pour éviter les fautes de frappe et limiter la variable body pour correspondre à ITodo, puis créer un nouveau Todo basé sur le modèle.

Avec cela en place, nous pouvons maintenant enregistrer le Todo dans la base de données et renvoyer une réponse contenant le todo créé et le tableau todos mis à jour.

  • controllers/todos/index.ts
const updateTodo = async (req: Request, res: Response): Promise<void> => {
  try {
    const {
      params: { id },
      body,
    } = req
    const updateTodo: ITodo | null = await Todo.findByIdAndUpdate(
      { _id: id },
      body
    )
    const allTodos: ITodo[] = await Todo.find()
    res.status(200).json({
      message: "Todo updated",
      todo: updateTodo,
      todos: allTodos,
    })
  } catch (error) {
    throw error
  }
}

Pour mettre à jour un todo, nous devons extraire l'id et body de l'objet req, puis les passer à findByIdAndUpdate(). Cet utilitaire trouvera le Todo dans la base de données et le mettra à jour. Et une fois l'opération terminée, nous pouvons maintenant renvoyer les données mises à jour à l'utilisateur.

  • controllers/todos/index.ts
const deleteTodo = async (req: Request, res: Response): Promise<void> => {
  try {
    const deletedTodo: ITodo | null = await Todo.findByIdAndRemove(
      req.params.id
    )
    const allTodos: ITodo[] = await Todo.find()
    res.status(200).json({
      message: "Todo deleted",
      todo: deletedTodo,
      todos: allTodos,
    })
  } catch (error) {
    throw error
  }
}

export { getTodos, addTodo, updateTodo, deleteTodo }

La fonction deleteTodo() permet de supprimer un Todo de la base de données. Ici, nous extrayons l'identifiant de req et le passons comme argument à findByIdAndRemove() pour accéder au Todo correspondant et le supprimer de la base de données.

Ensuite, nous exportons les fonctions pour pouvoir les utiliser dans d'autres fichiers. Cela dit, nous pouvons maintenant créer des routes pour l'API et utiliser ces méthodes pour gérer les requêtes.

Créer les routes de l'API

  • routes/index.ts
import { Router } from "express"
import { getTodos, addTodo, updateTodo, deleteTodo } from "../controllers/todos"

const router: Router = Router()

router.get("/todos", getTodos)

router.post("/add-todo", addTodo)

router.put("/edit-todo/:id", updateTodo)

router.delete("/delete-todo/:id", deleteTodo)

export default router

Comme vous pouvez le remarquer ici, nous avons quatre itinéraires pour récupérer, ajouter, mettre à jour et supprimer des todos de la base de données. Et puisque nous avons déjà créé les fonctions, la seule chose que nous avons à faire est d'importer les méthodes et de les passer en paramètres pour gérer les requêtes.

Jusqu'à présent, nous avons beaucoup couvert, cependant, nous n'avons toujours pas de serveur à démarrer. Alors, corrigeons cela dans la section suivante.

Créer un serveur

Avant de créer le serveur, nous devons d'abord ajouter des variables d'environnement qui contiendront les informations d'identification MongoDB dans le fichier nodemon.json.

  • nodemon.json
{
    "env": {
        "MONGO_USER": "your-username",
        "MONGO_PASSWORD": "your-password",
        "MONGO_DB": "your-db-name"
    }
}

Vous pouvez obtenir les informations d'identification en créant un nouveau cluster sur MongoDB Atlas.

  • app.ts
import express, { Express } from "express"
import mongoose from "mongoose"
import cors from "cors"
import todoRoutes from "./routes"

const app: Express = express()

const PORT: string | number = process.env.PORT || 4000

app.use(cors())
app.use(todoRoutes)

const uri: string = `mongodb+srv://${process.env.MONGO_USER}:${process.env.MONGO_PASSWORD}@clustertodo.raz9g.mongodb.net/${process.env.MONGO_DB}?retryWrites=true&w=majority`
const options = { useNewUrlParser: true, useUnifiedTopology: true }
mongoose.set("useFindAndModify", false)

mongoose
  .connect(uri, options)
  .then(() =>
    app.listen(PORT, () =>
      console.log(`Server running on http://localhost:${PORT}`)
    )
  )
  .catch(error => {
    throw error
  })

Ici, nous commençons par importer la bibliothèque express qui nous permet d'accéder à la méthode use(). Cette dernière nous permet de gérer les routes Todos.

Ensuite, nous utilisons le package mongoose pour nous connecter à MongoDB en ajoutant à l'URL les informations d'identification contenues dans le fichier nodemon.json.

Cela dit, maintenant, si nous nous connectons avec succès à MongoDB, le serveur démarrera, le cas échéant, une erreur se produira.

Nous avons maintenant terminé la construction de l'API avec Node, Express, TypeScript et MongoDB. Commençons maintenant à créer l'application côté client avec React et TypeScript.

Côté client avec React et TypeScript

Configuration

Pour créer une nouvelle application React, je vais utiliser Create React App. Vous pouvez également utiliser d'autres méthodes si vous le souhaitez.

Alors, exécutez dans le terminal la commande suivante.

  npx create-react-app my-app --template typescript

Ensuite, installez la bibliothèque Axios pour pouvoir récupérer les données distantes.

  yarn add axios

Une fois l'installation terminée, structurons notre projet comme suit.

├── node_modules
├── public
├── src
|  ├── API.ts
|  ├── App.test.tsx
|  ├── App.tsx
|  ├── components
|  |  ├── AddTodo.tsx
|  |  └── TodoItem.tsx
|  ├── index.css
|  ├── index.tsx
|  ├── react-app-env.d.ts
|  ├── setupTests.ts
|  └── type.d.ts
├── tsconfig.json
├── package.json
└── yarn.lock

Ici, nous avons une structure de fichiers relativement simple. La principale chose à noter est src/type.d.ts qui contiendra les types. Et comme je les utiliserai sur presque tous les fichiers, j'ai ajouté l'extension .d.ts pour rendre les types disponibles globalement. Et maintenant, nous n'avons plus besoin de les importer.

Créer un type Todo

  • src/type.d.ts
interface ITodo {
  _id: string
  name: string
  description: string
  status: boolean
  createdAt?: string
  updatedAt?: string
}

interface TodoProps {
  todo: ITodo
}

type ApiDataType = {
  message: string
  status: string
  todos: ITodo[]
  todo?: ITodo
}

Ici, l'interface ITodo doit refléter la forme des données de l'API. Et comme nous n'avons pas de mongoose ici, nous devons ajouter des propriétés supplémentaires pour correspondre au type défini sur l'API. Ensuite, nous utilisons cette même interface pour le TodoProps qui est l'annotation de type pour les accessoires qui seront reçus par le composant responsable du rendu des données.

Nous avons maintenant défini nos types - commençons maintenant à récupérer les données de l'API.

Récupérer les données de l'API

  • src/API.ts
import axios, { AxiosResponse } from "axios"

const baseUrl: string = "http://localhost:4000"

export const getTodos = async (): Promise<AxiosResponse<ApiDataType>> => {
  try {
    const todos: AxiosResponse<ApiDataType> = await axios.get(
      baseUrl + "/todos"
    )
    return todos
  } catch (error) {
    throw new Error(error)
  }
}

Comme vous pouvez le voir, nous devons importer axios pour demander des données à l'API. Ensuite, nous utilisons la fonction getTodos() pour récupérer les données du serveur. Il renverra une promesse de type AxiosResponse qui contient les Todos récupérés qui doivent correspondre au type ApiDataType.

  • src/API.ts
export const addTodo = async (
  formData: ITodo
): Promise<AxiosResponse<ApiDataType>> => {
  try {
    const todo: Omit<ITodo, "_id"> = {
      name: formData.name,
      description: formData.description,
      status: false,
    }
    const saveTodo: AxiosResponse<ApiDataType> = await axios.post(
      baseUrl + "/add-todo",
      todo
    )
    return saveTodo
  } catch (error) {
    throw new Error(error)
  }
}

Cette fonction reçoit les données saisies par l'utilisateur comme argument et renvoie une promesse. Ici, nous devons omettre la propriété _id car MongoDB la créera à la volée.

  • src/API.ts
export const updateTodo = async (
  todo: ITodo
): Promise<AxiosResponse<ApiDataType>> => {
  try {
    const todoUpdate: Pick<ITodo, "status"> = {
      status: true,
    }
    const updatedTodo: AxiosResponse<ApiDataType> = await axios.put(
      `${baseUrl}/edit-todo/${todo._id}`,
      todoUpdate
    )
    return updatedTodo
  } catch (error) {
    throw new Error(error)
  }
}

Pour mettre à jour un Todo, nous devons transmettre les données mises à jour et le _id de l'objet. Ici, nous devons changer le status du Todo, c'est pourquoi je ne choisis que la propriété dont nous avons besoin avant d'envoyer la demande au serveur.

  • src/API.ts
export const deleteTodo = async (
  _id: string
): Promise<AxiosResponse<ApiDataType>> => {
  try {
    const deletedTodo: AxiosResponse<ApiDataType> = await axios.delete(
      `${baseUrl}/delete-todo/${_id}`
    )
    return deletedTodo
  } catch (error) {
    throw new Error(error)
  }
}

Ici, nous avons également une fonction qui reçoit en paramètre la propriété _id et renvoie une promesse.

Avec cela en place, nous pouvons maintenant aller dans le dossier components et ajouter du code significatif à ses fichiers.

Créer les composants

Ajouter un formulaire Todo

  • components/AddTodo.tsx
import React, { useState } from "react"

type Props = {
  saveTodo: (e: React.FormEvent, formData: ITodo | any) => void
}

const AddTodo: React.FC<Props> = ({ saveTodo }) => {
  const [formData, setFormData] = useState<ITodo | {}>()

  const handleForm = (e: React.FormEvent<HTMLInputElement>): void => {
    setFormData({
      ...formData,
      [e.currentTarget.id]: e.currentTarget.value,
    })
  }

  return (
    <form className="Form" onSubmit={e => saveTodo(e, formData)}>
      <div>
        <div>
          <label htmlFor="name">Name</label>
          <input onChange={handleForm} type="text" id="name" />
        </div>
        <div>
          <label htmlFor="description">Description</label>
          <input onChange={handleForm} type="text" id="description" />
        </div>
      </div>
      <button disabled={formData === undefined ? true : false}>Add Todo</button>
    </form>
  )
}

export default AddTodo

Comme vous pouvez le voir, nous avons ici un composant fonctionnel de type React.FC (FC signifie composant fonctionnel). Il reçoit comme accessoire la méthode saveTodo () qui permet de sauvegarder des données dans la base de données.

Ensuite, nous avons un état formData qui doit correspondre au type ITodo pour satisfaire le compilateur, c'est pourquoi nous le passons au crochet useState. Nous devons également ajouter un type alternatif ({}) car l'état initial sera un objet vide.

Et avec cela, nous pouvons maintenant avancer et afficher les données récupérées.

Afficher un Todo

  • components/TodoItem.tsx
import React from "react"

type Props = TodoProps & {
  updateTodo: (todo: ITodo) => void
  deleteTodo: (_id: string) => void
}

const Todo: React.FC<Props> = ({ todo, updateTodo, deleteTodo }) => {
  const checkTodo: string = todo.status ? `line-through` : ""
  return (
    <div className="Card">
      <div className="Card--text">
        <h1 className={checkTodo}>{todo.name}</h1>
        <span className={checkTodo}>{todo.description}</span>
      </div>
      <div className="Card--button">
        <button
          onClick={() => updateTodo(todo)}
          className={todo.status ? `hide-button` : "Card--button__done"}
        >
          Complete
        </button>
        <button
          onClick={() => deleteTodo(todo._id)}
          className="Card--button__delete"
        >
          Delete
        </button>
      </div>
    </div>
  )
}

export default Todo

Ici, nous devons étendre le type TodoProps et ajouter les fonctions updateTodo et deleteTodo pour gérer de manière appropriée les accessoires reçus par le composant.

Désormais, une fois l'objet Todo passé, nous pourrons l'afficher et ajouter les fonctions nécessaires pour mettre à jour ou supprimer un Todo.

Génial! nous pouvons maintenant aller dans le fichier App.tsx et ajouter la dernière pièce au puzzle.

Récupérer et afficher les données

  • App.tsx
import React, { useEffect, useState } from 'react'
import TodoItem from './components/TodoItem'
import AddTodo from './components/AddTodo'
import { getTodos, addTodo, updateTodo, deleteTodo } from './API'

const App: React.FC = () => {
  const [todos, setTodos] = useState<ITodo[]>([])

  useEffect(() => {
    fetchTodos()
  }, [])

  const fetchTodos = (): void => {
    getTodos()
    .then(({ data: { todos } }: ITodo[] | any) => setTodos(todos))
    .catch((err: Error) => console.log(err))
  }

Ici, nous devons d'abord importer les composants et les fonctions utilitaires contenus sur API.ts. Ensuite, nous passons à useState un tableau de type ITodo et l'initialisons avec un tableau vide.

La méthode getTodos() renvoie une promesse; par conséquent, nous pouvons accéder à la fonction then et mettre à jour l'état avec les données extraites ou lancer une erreur au cas échéant.

Avec cela en place, nous pouvons maintenant appeler la fonction fetchTodos() lorsque le composant est monté avec succès.

  • App.tsx
const handleSaveTodo = (e: React.FormEvent, formData: ITodo): void => {
  e.preventDefault()
  addTodo(formData)
    .then(({ status, data }) => {
      if (status !== 201) {
        throw new Error("Error! Todo not saved")
      }
      setTodos(data.todos)
    })
    .catch(err => console.log(err))
}

Une fois le formulaire soumis, nous utilisons addTodo() pour envoyer la demande au serveur, puis si le Todo a été enregistré avec succès, nous mettons à jour les données sinon une erreur sera générée.

  • App.tsx
const handleUpdateTodo = (todo: ITodo): void => {
  updateTodo(todo)
    .then(({ status, data }) => {
      if (status !== 200) {
        throw new Error("Error! Todo not updated")
      }
      setTodos(data.todos)
    })
    .catch(err => console.log(err))
}

const handleDeleteTodo = (_id: string): void => {
  deleteTodo(_id)
    .then(({ status, data }) => {
      if (status !== 200) {
        throw new Error("Error! Todo not deleted")
      }
      setTodos(data.todos)
    })
    .catch(err => console.log(err))
}

Les fonctions pour mettre à jour ou supprimer un Todo sont assez similaires. Ils reçoivent tous les deux un paramètre, envoient la demande et récupèrent une réponse. Et puis, ils vérifient si la demande a réussi et la traitent en conséquence.

  • App.tsx
  return (
    <main className='App'>
      <h1>My Todos</h1>
      <AddTodo saveTodo={handleSaveTodo} />
      {todos.map((todo: ITodo) => (
        <TodoItem
          key={todo._id}
          updateTodo={handleUpdateTodo}
          deleteTodo={handleDeleteTodo}
          todo={todo}
        />
      ))}
    </main>
  )
}

export default App

Ici, nous parcourons le tableau todos puis passons auTodoItem les données attendues.

Maintenant, si vous parcourez le dossier qui contient l'application côté serveur et vous exécutez la commande suivante dans le terminal.

yarn start

Et aussi sur l'application côté client:

yarn start

Vous devriez voir que notre application Todo fonctionne comme prévu.

app

Génial! Avec cette touche finale, nous avons maintenant terminé la création d'une application Todo à l'aide de TypeScript, React, NodeJs, Express et MongoDB.

Vous pouvez trouver le Code source ici

Merci d'avoir lu.

Ressources

Aide-mémoire React TypeScript

Aide-mémoire sur les types de script avancés (avec exemples)

Aide-mémoires TypeScript

#typescript#react#node

Support my work

Get articles in your inbox