React :

Introduction :

Vous avez sûrement déjà entendu parler de React.

Utilisée par les entreprises géantes de la Tech comme Facebook, X ou Netflix, il s'agit de l'une des bibliothèques JavaScript les plus populaires pour construire des interfaces web. Son approche par composants réutilisables en fait un outil particulièrement modulaire pour développer vos applications.

Lorsqu'on parle de frameworks JavaScript, les noms de React, Vue.js et Angular ne sont jamais bien loin. Et il en existe d'autres (Ember, Meteor, Backbone...). Chacun a ses spécificités, ses avantages et ses inconvénients.

React est un projet open-source, distribué sous la licence MIT et piloté par Facebook. Leurs produits web et mobile tels que Facebook, Messenger, Instagram, reposent en grande partie sur cette technologie. Comme React est open-source, vous pouvez accéder au code source directement sur GitHub, proposer une feature, ou même notifier d'un problème (issue).

L'ambition de React est de créer des interfaces utilisateurs, avec un outil rapide et modulaire. L'idée principale derrière React est que vous vous construisez votre application à partir de composants. Un composant regroupe à la fois le HTML, le JS et le CSS, créés sur mesure pour vos besoins, et que vous poubez réutiliser pour construire des interfaces utilisateurs.

Sur sa documentation, React se présente comme "une bibliothèque JavaScript pour créer des interfaces utilisateurs." Pourtant, depuis tout à l'heure je vous parle de framework. En fait, la frontière entre framework et bibliothèque reste assez fine, surtout dans le cas de React; et il n'est pas simple de séparer parfaitement l'un de l'autre.

Pour faire simple, vous pouvez vous dire qu'un framework est un ensemble d'outils ultra complets permettant de créer une application de A à Z et fournissant les outils nécessaires au développement d'une application. Alors qu'une bibliothèque s'ajoute à une partie de votre application.

Les bibliothèques et les frameworks interagissent différemment avec le code.

Angular, par exemple, qui permet de créer une solution complète où tous les outils sont déjà fournis, comme une solution dédiée au routing ou bien aux appels API, est un framework, pour lequel on attend de vous de respecter une certaine architure.

En revanche, avec une bibliothèque, tout est beaucoup plus flexible. La bibliothèqyes vous fournit un ensemble de ressources que vous poubez combiner avec d'autres bibliothèques pour construire votre application.

À proprement parler, React... est une bibliothèqye. Mais son écosystème est tellement développé maintenant qu'on peut aussi bien le considérer comme un framexork à part entière.

Je vous parlais plus tôt des avanatages/inconvénients de chaque framework. Sans même rentrer dans l'aspect technique, voici quelques-uns des atouts de React :

Sa communauté :
Particulièrement active, elle vous facilite la vie. Lorsque vous cherchez votre problème sur Internet, il est quasiment impossible que personne n'ait déjà rencontré le même problème que vous. D'autant plus que React compte de très grosses entreprises parmi ses utilisateurs (Netflix, Twitter, Paypal, Airbnb pour n'en citer que quelques-unes). Vous pouvez être sûr qu'un autre ingéneirur s'est déjà trouvé confronté à votre problème. Par exemple, lorsque vous trouvez une question posée sur Stack Overflow (en anglais). L'équipe de React répond également aux issues (problèmes) sur le repository GitHub de React (en anglais). Mais il existe un nombre de newsletters, blogs, chaînes YouTube, créées par des utilisateurs - leur dynamique vous donne toujours envie de tester de nouveaux outils.
Sa documentation :
La documentation de React est riche, régulièrement mise à jour et intégralement traduite en français.
Ses opportunités professionnelles :
Comme il s'agit d'un des frameworks les plus populaires, les opportunités professionnelles sont particulièrement nombreuses. Dans l'enquête annuelle State of JS de 2022, 100% des personnes déclaraient connaître React, et sur 33338 sondés, 27289 déclaraient utiliser React (qui a d'ailleurs gagné l"Award 2019 de la technologie la plus utilisée).

Transformez un simple fichier HTML en app React :

Nous allons maintenant transformer un fichier HTML en app React.

Information

La méthode que je vous montre ici n'est pas une méthode utilisée dans le monde du travail. Il s'agit d'un moyen de vous montrer les bases sans aoir à faire trop de paramétrage, et sans avoir à utiliser trop d'outils tels que Webpack. Ici, nous pouvons nous concentrer sur l'essentiel.

Nous allons utiliser des liens CDN (Content Deklivery Network) pour ajouter React à un fichier HTML.

On oublie pas d'aller chercher le lien CDN de Babel et on rajoute une autre balise <script> :

<div id="root">Bonjour</div>
<script type="text/babel">
    ReactDom.render(<div>Mon élément remplace le contenu précédent</div>,
                    document.getElementById('root')
                   )
</script>

Créez votre premier composant :

Écrivons maintenant notre premier composant.

Ici, je vais utiliser des composants qu'on appelle functional components (composants fonction), c'est-à-dire une fonction qui retourne un élément React. Commençons avec cette prem!re fonction, MyComponent :

function MyComponent() {
    return (<div>Hello World !</div>)
}

En copiant ce code dans la partie JS, rien ne se passe. Pas de panique ! C'est normal : il vous reste encore à attacher votre composant React à votre HTML.

On va dès maintenant utiliser ReactDOM pour s'atatcher à notre HTML.

Dans le code ci-dessous, l'id root permet de préciser où notre app React va vivre dans notre HTML. Ensuite, on va ordonner à ReactDOM de générer (render) notre composant React qui s'appelle MyComponent.

ReactDOM.render(<,MyComponent />, document.getElementById('root'))

Et tadaaa ! Notre composant s'affiche !

Information

Il existe différentes manières de créer des composants en React. Il y a peu de temps encore, la manière la plus utilisée était d'utiliser des composants classes, avec la syntaxe :

class MyComponent extends React.Component

Cette manière était la seule pour utiliser des fonctionnalités qui sont au coeur de React. Mais depuis la mise à jour de React 16.8 en 2019, les composants fonctions permettent aussi de gérer tout ça.

Les composants sont essentiels dans React.

Une interface est toujours constituée de différents éléments : des boutons, des listes, des titres, des sous-tritres. Une fois rassemblés, ces éléments constituent une interface utilisateur ou UI. Si je prends l'exemple de la maquette du site de plantes ci-dessous, vous voyez la barre de menu, le panier, et que pour chaque article, il y a un nom, une photo, une description.

Avec React, chacune de ces parties qu'on va pouvoir réutiliser correspond à un composant. Ils contiennent tout ce qui est nécessaire à leur bon fonctionnement : la structure, les styles et le comportement (par exemple, les actions qui sont déclenchées quand on clique dessus).

Les composants nous permettent d'utiliser la même structure de données, et de remplir ces structures avec différents jeux de données. Peu importe le nombre de plantes que vous aurez à mettre dans La maison jungle, vous pourrez les exploiter pour afficher vos données sans aucun effort. Et si dans le futur, vous avez besoin de créer une nouvelle page avec la même présentation, vous pourrez réutiliser le même composant: vous vous rendez compte de la puissance des composants ?

C'est donc la mission des développeurs et développeurses React de découper toute interface utilisateur en éléments réutilisables, imbriqués les uns dans les autres. La majorité de nos composants sont eux-mêmes créés en combinant d'autres composants plus simples.

Derrière chaque technologie, il y a une logique.

Vous savez que le HTML est une suite d'instructions que le navigateur doit suivre pour construire le DOM. Eh bien, react vient directement modifier le DOM pour vous ; il suffit juste de lui donner les instructions de ce que vous souhaitez faire.

Pour faire simple : en utilisant React.createElement, React crée d'abord ses éléments dans le DOM virtuel, puis il vient prévenir le DOM au moment de sa créatuibn "Hé, rajoute-moi une balise h1 avec le texte La maison jungle dedans".

<div id="root"></div>

const Header = React.createElement('h1', {}, 'La maison jungle')

console.log(Header)

ReactDOM.render(Header, document.getElementById("root"))

En faisant un console.log de votre composant, voilà ce que vous obtenez :

Un console.log de notre premier composant

Header est ici un élément React, créé par React. Concrètement, il s'agit d'un gros objet. Chaque composant génère des arborescences d'éléments React et d'autres composants, qui seront ensuite traduits en éléments dans le DOM.

Information

C'est un peu comme lorsque vous créez en JavaScript un nouvel élément avec document.createElement et que vous l'ajoutez au DOM avec la méthode .appendChild().

Initiez-vous au JSX :

Vous avez découvert que les éléments renvoient des objets avec tout un ensemble de propriétés spécifiques, et que React vient les créer avec createElement. Mais vous vous doutez que les développeurs React ne manipulent pas ces objets directement. Non, à la place, ils utilisent le JSX.

Il s'agit de l'extension JavaScript créée par React, qui permet d'utiliser notre syntaxe sous forme de tages directement dans le code JavaScript.

Lorsqu'on a ça :

function Header() {
    return (<div>
        <h1>La maison jungle</h1>
    </div>)
}

... et qu'on le réutilise avec <Header />, on pourrait croire qu'il s'agit de HTML. Ça a un peu la même tête, mais il s'agit de JSX ! Eh oui, JSX est la manière la plus compréhensible d'écrire dfes composants React dans une application, et donc la manière qui est quasiment toujours utilisée. Il s'agit d'ailleurs de la sépcificité de React : contrairement aux autres frameworks où on écrit du HTML enrichi, les équipes de React ont créé le JSX, leur propre syntaxe basée sur JavaScript, qui permet de mêler HTML et JS.

Comme le HTML, le JSX est un langage à balises. Les touches <, > et / de vos claviers vont donc être souvent utilisées.

Important

On a bien créé un composant Header et pas header. Il est essentiel de mettre une majuscule à nos composants JSX, sinon React ne saura pas qu'il s'agit d'un composant, et pensera qu'il s'agit juste d'une balise HTML.

Composez vos composants :

Reprenons notre composant <Header />. Il est un peu tout seul, vous ne trouvez pas ?

On va lui ajouter un composant <Description /> :

function Description() {
    return (<p>Ici achetez toutes les plantes dont vous avez toujours rêvé</p>)
}

Et on le rajoute dans le render :

ReactDOM.render(<Header /><Description />, document.getElementById("root"))

Quoi ?! Rien ne s'affiche sauf un point d'exclamation entouré de rouge : il y a une erreur !

Pas de panique, c'est normal : deux composants doivent toujours être wrappés dans un seul composant parent.

On peut donc faire :

ReactDOM.render(<div><Header /><Description /></div>, document.getElementById("root"))

et le problème est résolu.

Information

React met également à notre disposition un outil, les Fragments, si on veut wrapper deux composants dans un seul parent sans que le parent apparaisse dans le DOM. Pour ça, vous pouvez faire :

ReactDOM.render(<React.Fragment><Header /><Description /></React.Fragment>, document.getElementById("root"))

Nos éléments sont bien wrappés, et si vous inspectez votre page, ce parent n'apparaît pas dans le DOM.

Comme je vous l'ai expliqué, le propre de react est de nous encourager à réutiliser nos composants. On va donc structurer notre interface en arborescences de composants.

Regroupons notre Titre et notre Description dans une bannière :

function Banner() {
    return (<div>
        <Header />
        <Description />
    </div>)
}

Vous voyez ? Tout se passe bien comme prévu.

Et nous pourrions également les encapsuler, et les utiliser autant de fois que nous le souhaitons comme ci-dessous :

<Parent>
    <Enfant />
    <Enfant />
    <Enfant />
</Parent>
Important

Vous remarquez d'aillez que tous nos composants sont bien fermés. En JSX, toutes les balises doivent être fermées, y compris les éléments HTML autofermants tels que input. On l'écrira de cette manière (en lui ajoutant les attributs souhaités) :

<input />

Manipulez des données dans vos composants JSX :

En React, les accolades { et } sont également particulièrement utiles. Dès qu'il s'agit d'expressions JavaScript, elles sont écrites entre accolades.

Ça nous permet d'appliquer des expressions JavaScript directement dans notre JSX pour :

  • faire des maths :

    <div>La grande réponse sur la vie, l'univers et le reste est { 6 * 7 } </div>
  • modifier des chaînes de caractères :

    <div>{ alexia.toUpperCase() }</div>
  • utiliser des ternaires :

    <div>{ 2 > 0 ? 'Deux est plus grand que zéro' : 'Ceci n\'apparaîtra jamais' }</div>

Ou même tout simplement pour afficher une variable JS :

  • pour une string :

    <div>{ myTitle }</div>
  • pour un nombre :

    div>{ 42 }</div>

Par exemple, si on décide de mettre notre texte ou description dans une variable :

function Description() {
    const text = "Ici achetez toutes les plantes dont vous avez toujours rêvées"
    return (<p>{ text }</p>)
}

ça s'affiche bien comme prévu.

Prenez en main Create React App :

Nous avons appris à utiliser les liens CDN de React, ReactDOM et Babel pour rapidement créer une app React. Mais cette technique n'est quasiment pas utilisée dans la vie de tous les jours d'un développeur.

À la place, les développeuses et développeurs utilisent des outils automatisés pour créer une base de code, qui dispose des outils essentiels déjà préconfigurés. Pour vous citer quelques-unes des fonctionnalités de ces outils, ils permettent de :

  • gérer les différentes dépendances (bibliothèques) utilisées par notre app;

  • optimiser le chargement de notre code dans les navigateurs;

  • importer du CSS et des images;

  • gérer les différentes versions de JavaScript;

  • faciliter l'expérience de développement, en rechargeant la page lorsque le code est modifié.

Découvrez CRA :

Également créé par les équipes de Facebook, Create React App est un outil qui vous aidera à faire tout ce que je viens de citer. S'il existe d'autres outils (Next, Gatsby, Parcel, etc.), Create React App reste la référence, notamment pour les nouveaux utilisateurs de React.

Create React App va permettre de générer un squelette de code pour votre application. Il embarque un certain nombre d'outils préconfigurés, tels que Webpack, Babel et ESLint, afin de vous garantie la meilleure expérience de développement possible.

Installez et lancez CRA :

Information

Pour manipuler Create React App ici, nous allons avoir besoin d'un gestionnaire de paquet (package manager) directement dans le terminal. Ici, je vais utiliser yarn. Si vous utilisez une autre version, telle que npm, je vous conseille de vous référer au guide d'utilisation de Create React App par Facebook, sur GitHub (en anglais).

D'ailleurs, si vous voulez en apprendre davantage sur pourquoi choisir npm ou Yarn, je vous conseille cet excellent article de blog qui compare les deux.

Pour commencer, placez-vous dans le dossier où vous voulez créer votre projet.

Pour initialiser votre projet, nous allons faire :

yarn create react-app la-maison-jungle

Grâce à cette commande, vous avez votre premier projet créé avec Create React App !

Découvrez les fichiers :

Maintenant que votre projet est initialisé, il est temps de vous plonger dedans et de partir à l'exploration des fcihiers créés. On rentre dans le projet avec cd la-maison-jungle dans votre terminal.

À partir de là, vous pouvez ouvrir votre éditeur de texte préféré pour jeter un oeil.

Information

Votre environnement de travail est particulièrement important. Vous devez vous sentir à l'aise avec les outils que vous utilisez. VS Code vous permet d'installer des extensions qui vous offrent des fonctionnalités supplémentaires. Typiquement, pour un projet React, les extensions Prettier et ESLint sont très utiles !

Vous trouverez trois dossiers :

  • node_modules : c'est là que sont installées toutes les dépendances de notre code. Ce dossier peut vite devenir très volumineux.

  • public : dans ce dossier, vous trouverez votre fichier index.html et d'autres fichiers relatifs au référencement web de votre page.

  • src : vous venez de renrer dans le coeur de l'action. L'essentiel des fichiers que vous créerez et modifierez seront là.

Et faisons maintenant un petit tour des fichiers importants :

  • package.json situé à la racine de votre projet, il vous permet de gérer vos dépendances (tous les outils permettant de construire votre projet), vos scripts qui peuvent être exécutés avec yarn, etc. Si vous examinez son contenu, vous pouvez voir des dépendances que vous connaissez : React et ReactDOM :

    • vous y trouverez react-scripts, créé par Facebook, qui permet d'installer Webpack, Babel, ESLint et d'autres pour vous faciliter la vie

  • dans /public, vous trouvez index.html. Il s'agit du template de votre application. Il y a plein de lignes de code, mais vous remarquez <div id="root"></div> ? Comme précédemment, nous allons y ancrer notre app React

  • dans /src, il y a index.js qui permet d'initialiser notre app React;

  • et enfin, dans /src, vous trouvez App.js qui est notre premier composant React.

Deux fichiers que nous n'utiliserons pas directement mais qui ne font pas de mal à garder :

  • le README.md qui permet d'afficher une page d'explication si vous mettez votre code sur GitHub, par exemple

  • et le fichier .gitignore qui précise ce qui ne doit pas être mis sur GitHub, typiquement le volumineux dossier des node_modules.

Choisir une version spécifique de React :

Pour mettre à jour les dépendances, nous allons suivre différentes étapes :

  1. Rendez-vous au fichier package.json dans la section depencies. Modifiez cette section avec le code suivant, correspondant aux dépendances nécessaires à React 17 :

    "dependencies": {
        "@testing-library/jest-dom": "^5.11.4",
        "@testing-library/react": "^11.1.0",
        "@testing-library/user-event": "^12.1.10",
        "react": "^17.0.1",
        "react-dom": "^17.0.1",
        "react-scripts": "4.0.1",
        "web-vitals": "^0.2.4"
    },
  2. Supprimez le fichier yarn.lock (ou package.lock si vous avez utilisé npm) ainsi que le dossier de nos dépendances node_modules.

  3. Dans le dossier src, modifiez le fichier index.js qui a été généré pour la dernière version de React avec le code correspondant à la version de React 17, comme ceci :

    import React from 'react';
    import ReactDOM from 'react-dom';
    import './index.css';
    import App from './App';
    
    ReactDOM.render(
        <React.StrictMode>
            <App />
        </React.StrictMode>,
        document.getElementById('root')
    );
  4. Lancer la commande yarn pour installer les dépendaces.

Vous pouvez procéder de la même manière pour installer n'importe quelle version spécifique de React dans votre application. En suivant le cheminement ci-dessus vous serez à même de suivre la suite de ce cours sans difficulté.

Prenez en main votre app avec les commandes :

Lorsque vous vous trouvez à la racine de votre projet, vous pouvez exécuter yarn start qui va démarrer votre application en mode développement.

Cela vous donne quelque chose comme ça (même si votre adresse IP sera très probablement différente) :

Un onglet a dû s'ouvrir dans votre navigateur à l'URL http://localhost:3000/. Si ce n'est pas le cas, ouvrez-le vous-même.

Vous avez le magnifique logo de React qui tourne dans votre navigateur.

Information

Il existe d'autres commandes :

  • yarn run build vous permettra de créer un build avec votre code transformé et minifié, si vous devez déployer votre application en production (la mettre en ligne, par exemple);

  • yarn test pour exécuter les tests.

Vous pouvez d'ailleurs créer vos propres commandes si vous les ajoutez dans la partie scripts.

Organisez votre code :

Nous allons maintenant modifier notre base de code pour qu'elle soit plus à l'image de notre projet. Il existe plusieurs manières d'organiser son code, et il est important de réfléchir à comment l'organiser. Ici, nous allons séparer les fichiers selon leur type : composants/style/images, etc.

On va commencer par créer un dossier /components dans /src, où nous mettrons tous nos composants. On y glisse App.js et on en profite pour changer le chemin d'import dans index.js. Pour ce qui est des autres fichiers, le plus important est index.js que vous devez garder. Vous pouvez également garder index.css, mais vous pouvez supprimer les autres fichiers.

Maintenant, créons notre Banner dans un fichier JavaScript à part dans /components que nous pouvons appeler Banner.js.

function Banner() {
    return <h1>La maison jungle</h1>
}

export default Banner
Information

Vous remarquez la notation export default ? Il s'agit d'une syntaxe prévue dans l'ES6, qui vous épargnera d'utiliser les accolades au moment de l'import.

On peut maintenant adapter le code de App.js en supprimant le code de base, et y importer notre Banner.

import Banner from './Banner'

function App() {
    return <Banner />
}

export default App

Ce qui nous donne :

Notre Banner s'affiche dans le navigateur.

Félicitations ! Comme je l'ai déjà mentionné, mais ici, c'est Webpack qui nous permet d'importer notre composant aussi facilement, avec import. Cet outil particulièrement utile est essentiel pour lier les fichiers entre eux, afin qu'ils soient interprétés par le navigateur. Et dire que Create React App nous a permis de l'installer sans faire aucune configuration. Si ça c'est pas de la chance !

Import vos composants grâce à Webpack

Stylisez votre app :

C'est maintenant le moment d'ajouter un peu de style à nos composants.

Exploitez les classNames :

Comme en HTML, nous pouvons associer des attributs à nos éléments. Les attributs HTML tels que id, href pour un lien <a />, src pour une balise <img />, fonctionnent normalement en JSX.

En revanche, il existe des mots réservés en JavaScript, tels que class. Pour attribuer du style avec une classe CSS, il suffit pour cela d'utiliser l'attribut className, et de lui préciser une string. D'ailleurs, vous pouvez utiliser plusieurs classes sur un élément en les mettant à la suite, séparées par un espace.

Créons dans /src un dossier /styles qui va regrouper nos fichiers CSS. On peut y glisser index.css en n'oubliant pas de modifier le path relatif pour importer index.css dans index.js.

Je crée donc mon fichier Banner.css qui va me permettre de styliser mon composant. Ce qui nous donne une organisation comme dans la capture ci-dessous :

L'arborescence de notre app

Dans Banner.js, je wrappe mon h1 dans une div à laquelle je précise la className lmj-banner ("lmj" pour la maison jungle, bien sûr !) :

<div className='lmj-banner'>
    <h1>La maison jungle</h1>
</div>

Et retour dans notre fichier Banner.css, où on crée la classe correspondante :

.lmj-banner {
    color: black;
    text-align: right;
    padding: 32px;
    border-bottom: solid 3px black;
}

Jetons un oeil à notre page dans le navigateur.

Rien ne se passe car nous n'avons tout simplement pas importé le fichier. Il suffit de rajouter dans notre fichier Banner.js :

import '../styles/Banner.css'

Et ça y est ! Notre style est appliqué !

En React comme dans toutes les librairies et tous les frameworks front, l'accessibilité du web est essentielle. Elle est nécessaire pour permettre aux technologies d'assistance et aux personnes en situation de handicap, notamment, d'interpréter les pages web. Pour en savoir plus, je vous conseille "Concevez un contenu web accessible", un excellent cours sur ce sujet.

Découvrez l'attribut style :

Bonne nouvelle : les éléments React acceptent également l'attribut style pour styliser un composant. À la différence des éléments HTML, pour lesquels cet attribut est également accepté, il faut lui passer un objet en paramètre. On appelle cette méthode du inline style :

import '../styles/Banner.css'

function Banner() {
    return (
        <div
                style={{
                    color: 'black',
                    textAlign: 'right',
                    padding: 32,
                    borderBottom: 'solid 3px solid'
                }}
        >
            <h1>La maison jungle</h1>
        </div>
    )
}

export default Banner

Cet attribut peut être pratique pour tester rapidement quelque chose, mais il n'est pas recommandé d'en faire une utilisation plus poussée. Donc, pour styliser votre application, privilégiez davantage la méthode des classNames, ou d'autres méthodes avec des librairies tierces, par exemple.

Utilisez des images :

Vous avez peut-être vu le logo.svg de React situé dans /src ?

Ici, nous allons faire un peu la même chose. Mais commençons par organiser tout ça.

Toujours dans /src , on crée un dossier /assets dans lequel on vient mettre notre fichier logo.png qui voici :

Pour l'importer dans votre code, vous pouvez maintenant faire de la manière suivante . Dans Banner.js :

import logo from '../assets/logo.png'

Vous voyez ici, on déclare en fait une variable logo à laquelle on assigne le contenu de notre image.

Puis vous pouvez l'utiliser dans un élément img, ce qui nous donne pour notre Banner.js :

import logo from '../assets/logo.png'
import '../styles/Banner.css'

function Banner() {
    const title = 'La maison jungle'
    return (
        <div className='lmj-banner'>
            <img src={logo} alt='La maison jungle' className='lmj-logo' />
            <h1 className='lmj-title'>{title}</h1>
        </div>
    )
}

export default Banner

En ajoutant un peu de CSS, me voilà avec la bannière telle que je la voulais.

Vous avez maintenant une très bonne base de code pour créer votre application, en utilisant du style et des assets directement dans vos composants React.

Itérez sur votre contenu :

En code, vous serez très souvent confronté à des listes de données qui présentent la même struture. Bonne nouvelle ! Pas besoin de vous faire des crampes à force de copier-coller : vous pouvez directement itérer sur votre contenu et générer des composants react dessus.

Découvrez votre allié : map() :

La méthode JavaScript map() passe sur chaque élément d'un tableau. Elle lui applique une fonction, et renvoie un nouveau tableau contenant les résultats de cette fonction appliquée sur chaque élément.

Par exemple, pour une fonction qui doublerait la valeur d'un élément, cela donne :

const numbers = [1, 2, 3, 4]
const doubles = numbers.map(x => x * 2) // [ 2, 4, 6, 8]

Dans notre cas, ekke ca biys oerlerrre de prendre une liste de données, et de la transformer en liste de composants.

Information

La méthode map() permet facilement d'itérer sur des données et de retourner un tableau d'éléments. Comme elle, les méthodes forEach(), filter(), reduce(), etc., qui permettent de manipuler des tableaux, seront également vos alliés en React.

On va donc créer un composant ShoppingList.js pour notre magain de plantes.

Dans ce fichier, on déclare une variable plantList qui contient notre liste de plantes :

const plantList = [
    'monstera',
    'ficus lyrata',
    'pothos argenté',
    'yucca',
    'palmier'
]

Et on ajoute en dessous le composant lui-même :

function ShoppingList() {
    return (
        <ul>
            {plantList.map((plant) => (
                <li>{plant}</li>
            ))}
        </ul>
    )
}

export default ShoppingList

Vous voyez : pour chaque entrée du tableau, on retourne un élément <li>.

On importe ShoppingList.js dans <App />. On a notre liste de composants !

Mais qu'est-ce que c'est que cette erreur rouge dans ma console ?

Oh oh ! Il semblerait que j'aie oublié la prop key.

La documentation de React est claire sur ce sujet : les key (clés) aident React à identifier quels éléments d'une liste ont changé, ont été ajoutés ou spprimés. Vous devez donner une clé à chaque élément dans un tableau, afin d'apporter aux éléménts une identité stable.

Si vous voulez éviter kes bugs, une key doit impérativement respecter deux principes :

  • Elle doit être unique au sein du tableau.

  • Et stable dans le temps (pour la même donnée source, on aura toujours la même valeur de key=).

Nous avons plusieurs méthodes pour générer une key unique :

  • La méthode la plus simple et la plus fiable consiste à utiliser l'id associée à votre donnée dans votre base de données.

  • Vous pouvez également trouver un moyen d'exploiter la valeur de la donnée, si vous avez la certitude qu'elle sera toujours unique, et stable dans le temps.

  • En dernier recours, vous pouvez définir une string et la combiner avec l'index de la data dans votre tableau.

Dans notre cas, puisqu'il n'y a pas d'id associée, on peut faire une combinaison entre l'index et le nom de la plante qui est une string :

function ShoppingList() {
    return (
        <ul>
            {plantList.map((plant, index) => (
                <li key={`${plant}-${index}`}>{ plant }</li>
            ))}
        </ul>
    )
}

export default ShoppingList

Cette fois-ci, pas d'erreur dans la console !

Contextualisez le contenu de vos composants :

React nous permet de faire des listes de composants : un gain de temps énorme dans votre vie de développeur. Mais ce n'est pas tout ! Le JSX nous permet également d'afficher des éléments de manière conditionnelle dans nos composants.

Créez des conditions dans le JSX :

Donc... Nous avons déjà vu ternaire. maintenant qu'on a une app complète pour nous faire la main, moi, ça me donne envie de le mettre en applicationdans notre site de plantes.

Dans notre liste de plantes plantList.js, je vais rajouter une catégorie isBestSale correspondant à un booléen qui nous indique si notre plante fait partie des meilleures ventes. Ce qui nous donne pour le premier élément :

{
    name: 'monstera',
    category: 'classique',
    id: '1ed',
    isBestSale: true
},

Maintenant que nous avons notre booléen, nous allons utiliser un ternaire pour afficher un emoji en fonction. Dans ShoppingList.js, au niveau de l'affichage du nom, je rajoute :

{plantList.map((plant) => (
    <li key={ plant.id }>
        {plant.isBestSale ? <span>🔥</span> : <span>👎</span>}
    </li>
))}

Ce qui nous donne :

Notre condition différencie les meilleures ventes des autres.

Génial ! Ça marche bien !

Mais en y repensant, je ne suis pas sûre que ce soit top comme argulent de vente... À la place, n'affichons que le 🔥 pour les meilleures ventes, et rien pour les autres.

Pour ça, on peut retourner null dans la condition où on ne veut rien afficher :

{plant.isBestSale ? <span>🔥</span> : null}

Et voilà ! On a ce que l'on voulait !

Mais vous savez quoi ? Il existe une manière encore plus simple d'écrirer ça : vous pouvez utiliser &&.

Indiquée entre accolades, && précède un élément JSX et précise que l'élément ne sera généré que si la condition est respectée. On peut donc écrire :

{plant.isBestSale && <span>🔥</span>}

Yes ! Ça fonctionne comme prévu !

Information

Vous pouvez d'ailleurs chaîner les conditions.

Ouvrez-vous à d'autres méthodes :

React est particulièrement flexible : il existe d'autres méthodes permettant de contextualiser votre contenu.

Familiarisez-vous avec les props :

Vous l'avez compris : la réutilisation des composants est au coeur de la logique de react. Mais, pour être réutilisés, les composants requièrent souvent une conviguration. Bonne nouvelle : pour ça, vous allez pouvoir utiliser les props.

Familiarisez-vous avec la syntaxe :

Et si je vous disais que vous avez déjà utilisé une prop ? Eh oui, la prop key sur les listes ! Vous avez donc déjà vu la syntaxe.

Revenons à notre site de plantes. Nous allons maintenant créer un nouveau composant qui va être réutilisé. L'idée est de créer une échelle d'arrosage et une échelle de luminosité pour chaque plante.

Rouvrons ShoppingList.js, où nous ajoutons les données correspondantes dans plantList :

  • une donnée water qui correspond à l'arrosage conseillé pour chaque plante;

  • et une donnée light qui correspond à l'ensoleillement nécessaire.

Commençons par la lumière : dans chaque item plante, on vient ajouter un composant CareScale et on lui passe la prop value :

<CareScale scalueValue={plant.light} />

Les props sont récupérées dans les paramètres de la fonction qui définit notre composant.

Pour CareScale, on aura donc :

function CareScale(props) {µ
    const scaleValue = props.scaleValue
    return <div>{scaleValue}☀️</div>
}

export default CareScale

Mais on avait dit qu'on voulait une échelle de 1 à 3, non ?

On va donc partir sur une liste, qu'on peut manuellement.

Ce qui nous donne :

function CareScale(props) {
    const scaleValue = props.scaleValue

    const range = [1, 2, 3]

    return (
        <div>
            {range.map((rangeElem) =>
                scaleValue >= rangeElem ? <span key={rangeElem.toString()}>☀️</span> : null
            )}
        
    )
}

Félicitations ! Vous venez d'utiliser les props !

Les props sont donc des objets que l'on peut récupérer dans les paramètres de notre composant fonction.

On va pousser la logique un peu plus loin afin de véritablement paramétrer notre composant.

Créez des paramètres :

Je vais commencer par préciser une prop pour le type que j'appellerai careType pour mon composant CareScale et réutiliser ce composant entre l'ensoleillement et l'arrosage :

<CareScale careType='water' scaleValue={plant.water} />
<CareScale careType='light' scaleValue={plant.light} />

Il faut maintenant que j'adapte CareScale pour récupérer le careType.

Information

À partir de maintenant, je vais utiliser une syntaxe qui nous est permise depuis l'ES6 : la déstructuration. Elle permet directement de déclarer une variable et de lui assigner la valeur d'une propriété d'un objet.

Ici on peut donc faire :

const {scaleValue, careType} = props
// On évite de multiplier les déclarations qui sans cette syntaxe auraient été :
// const scaleValue = props.scaleValue et
// const careType = props.careType

Cela nous permet de déclarer directement nos deux variables scaleValue et careType, et de les assigner aux valeurs passées en propos. On peut même directement l'écrire dans les paramètres :

function CareScale({scaleValue, careType}) {

On peut donc maintenant paramétrer notre composant CareScale pour qu'il puisse gérer les données d'arrosage et les données d'ensoleillement :

function CareScale({ scaleValue, careType }) {
    const range = [1, 2, 3]
    
    const scaleType = careType === 'light' ? '☀️' : '💧'

    return (
        <div>
            {range.map((rangeElem) => scaleValue >= rangeElem ? <span key={rangeElem.toString()}>{scaleType}</span> : null
            )}
        </div>
    )
}

Comme vous l'avez vu ici, nous avons utilisé deux syntaxes différentes pour nos props. Pour scaleType, nous lui avons assigné une string, entre guillemets. En revanche, pour scaleValue, nous lui avons attribué la valeur d'un objet, que nous avons passée entre accolades.

En pratique, une prop peut avoir n'importe quelle valeur possible en JavaScript, mais syntaxiquement, en JSX, on n'a en gros que deux possibilités :

  • un littéral String, matérialisé par des guillemets;

  • ou, pour toute le reste (booléen, number, expression JavaScript, etc.), des accolades {}.

Faites descendre les données, des parents vers les enfants :

Les props nous permettent de configurer nos composants. Elles répondent à la logique même de React selon laquelle les données descendent à travers notre arborescence de composants : il s'agit d'un flux de données unidirectionnel.

Les composants parents partagent leurs données avec leurs enfants.

Comme vous pouvez vous en douter, un composant est le parent du composant défini dans le return().

Dans notre exemple, CareScale est l'enfant, et ShoppingList est le parent.

Pour les props, vous devez garder deux règles à l'esprit :

  • Une prop est toujours passée par un composant parent à son enfant : c'est le seul moyen normal de transmission.

  • Une prop est considérée en lecture seule dans le composant qui la reçoit.

Découvrez la prop technique children :

Il existe des props qui ont un comportement un peu particulier : nous les appelons les props techniques.

La syntaxe de cette prop est particulière, puisqu'elle n'est pas fournie à l'aide d'un attribut, mais en imbriquant des composants à l'intérieur du composant concerné.

Ce qui nous donne :

<Parent>
    <Enfant1 />
    <Enfant2 />
</Parent>

Par exemple, si on utilise children pour réécrire la Banner, cela nous donnerait dans App.js :

<Banner>
    <img src={logo} alt='La maison jungle' />
    <h1 className='lmj-title'>La maison jungle</h1>
</Banner>

Ici, img et h1 sont les noeuds enfants dans le DOM de Banner.

Et on peut accéder à ces noeuds enfants de Banner dans ses paramètres, un peu de la même manière qu'on récupérerait des props :

function Banner({ children }) {
    return <div className='lmj-banner'>{children}</div>
}

Cette manière d'utiliser children et particulièrement utile lorsqu'un composant ne connaît pas ses enfants à l'avance, par exemple pour une barre de navigation (Sidebar) ou bien pour une modale.

Information

Les props constituent un aspect clé de React. Mais, en les manipulant, vous verrez qu'il peut être très facile de faire des erreurs. Cela vient notamment de la flexibilité de JavaScript, qui fait du typage dynamique (les types string, int, etc.). Pour vous donner un exemple d'erreur classique :

  • Vous pouvez passer une prop value à un composant.

  • Vous utilisez une liste de valeurs, certaines valeurs sont des strings, d'autres des nombres.

  • Vous appliquez la méthode .toUpperCase() à votre value : boum !

Une erreur ! .toUpperCase() n'existe pas sur un nombre.

Pour éviter ce genre d'erreur, je vous conseille d'être extrêmement rigoureux sur le type de props que vous passez à vos composants.

Pour cela, React a créé les PropTypes, qui nous permettent de préciser dès le début le type d'une prop, si elle est requise, et de lui attribuer une valeur par défaut.

Maîtrisez les évenements en React :

Si vous avez déjà manipulé du JavaScript, vous êtes sûrement déjà familier avec les événements. Bon, vous avez quand même droit à un petit rappel : un événement est une réaction à une action émise par l'utilisateur, comme le clic sur un bouton ou la saisie d'un texte dans un formulaire.

Bonne nouvelle pour vous : avec sa syntaxe pratique et concise, React facilite énormément la gestion des événements du DOM.

Familiarisez-vous avec ka syntaxe :

Quelques caractéristiques de la déclaration d'un événement en React :

  • l'événement s'écrit dans une balise en camelCase;

  • vous déclarez l'événement à capter, et lui passez entre accolades la fonction à appeler;

  • contrairement au JS, dans la quasi totalité des cas, vous n'avez pas besoin d'utiliser addEventListener.

Testons ça dès maintenant dans notre code. Dans components/PlantItem.js, je vais déclarer une finction handleClick qui vient faire un log dans notre console :

function handleClick() {
    console.log('Ceci est un clic')
}

On ajoute maintenant onClick={handleClick} dans la balise li du composant PlantItem. On a donc :

<li className='lmj-plant-item' onClick={handleClick}>
    <img className='img-plant-item-cover' src={cover} alt={`${name} cover`} />
    {name}
    <div>
        <CareScale careType='water' scaleValue={water} />
        <CareScale careType='light' scaleValue={light} />
    </div>
</li>

J'ouvre la console et ça fonctionne comme prévu !

Je vais pousser cet exercice un peu plus loin : on va déclencher une alerte qui affiche le nom de la plante sur laquelle on a cliqué.

On passe donc plantName en paramètre de handleClick comme ici :

function handleClick(plantName) {
    alert(`Vous voulez acheter 1 ${plantName} ? Très bon choix 🌱✨`)
}

Mais si je clique, ça ne marche pas :

Aucune de nos plantes ne s'appelle 1 [object Object].

En effet, React passe par défaut un objet (que nous aborderons dans quelques minutes), mais ici, nous voulons lui spécifier notre propre argument.

Pour cela, c'est très simple : on déclare une fonction directement dans onClick (les fonctions fléchées sont très pratiques pour ça). Cette fonction appellera handleClick en lui passant name en paramètre. Donc on a :

onClick={() => handleClick(name)}

Découvrez les événements synthétiques :

Donc, je vous parlais de l'objet que React passe par défaut en paramètre aux fonctions indiquées en callback des événements. Voyons voir à quoi ça ressemble.

Si je récupère le paramètre dans handleClick :

function handleClick(e) {
    console.log('Cecu est mon event :', e)
}

j'obtiens ça :

Il s'agit en fait d'un événement synthétique. Pour faire bref, il s'agit de la même interface que pour les événements natifs du DOM, sauf qu'ils sont compatibles avec toues les navigateurs.

Pratique, n'est-ce pas ?

Vous pouvez utiliser les méthodes preventDefault et stopPropagation avec le paramètre dans la fonction passée à l'événement. Dans notre cas, vous auriez pu faire e.preventDefault().

Information

Si vous voulez en savoir plus sur les événements synthétiques, vous trouverez une liste de tous les événements orus eb charge ici.

Simplifiez votre création de formulaires avec React :

En React, la gestion des formulaires est simplifiée : on a accès à la valeur très facilement, qu'il s'agisse d'un input checkbox, d'un textarea, ou encore d'un select avec onChange.

Il existe deux grandes manières de gérer les formulaires : la manière contrôlée et la manière non contrôlée. J'aborderai assez rapidement la manière non contrôlée, parce qu'elle nécessite moins d'implication de react, et que React encourage l'utilisation des formulaires contrôlés.

Déléguez le contrôle : les formulaires non contrôlés :

Je vous fais une petite démo d'un formulaire non contrôlé. Sur notre app; directement dans App.js, je mets un composant QuestionForm que je vais déclarer dans un fichier à part. Nous allons ajouter un champ pour une question.

Donc pour ça, je crée un form, qui englobe mon input :

<form onSubmit={handleSubmit}>
    <input type='text' name='my_input' defaultValue='Tapez votre texte' />
    <button type='submit'>Entrer</button>
</form>

Et pour handleSubmit, cela donne :

function handleSubmit(e) {
    e.preventDefault()
    alert(e.target['my_input'].value)
}

Vous voyez que React me permet de préciser une defaultValue à mon champ input. Ici, j'appelle preventDefault, sinon le submit rafraîchirait la page.

Et j'ai bien mon alerte qui se déclenche.

Plutôt simple, n'est-ce pas ? Vous déléguez le travail à votre DOM. Effectivement, les formulaires non contrôlés nous permettent de ne pas avoir à gérer trop d'informations. Mais cette approche est un peu moins "React", parce qu'elle ne permet pas de tout faire.

À la place, vous pouvez utiliser les composants contrôlés.

Contrôlez vos formulaires :

Information

Ici, pour vous montrer l'utilisation des formulaires contrôlés, je vais avoir besoin d'une notions que nous aborderons prochainement : le state (état). Donc je vais essayer de vous faire un petit brief sans rentrer dans le détail.

Le state local nous permet de garder des informations. Ces informations sont spécifiques à un composant et elles proviennent d'une iteraction que l'utilisateur a eue avec le composant.

Donc je vais créer ma variable inputValue et la fonction qui va permettre de changer sa valeur dans le state local avec useState.

Sachez juste que la ligne de code ci-dessous me permet de déclarer l'état initial pour inputValue et la fonction correspondante pour la modifier, et de lui préciser la valeur par défaut "Posez votre question ici" :

const [inputValue, setInputValue] = useState("Posez votre question ici")

J'ai donc mon QuestionForm comme ci-dessous :

import { useState } from 'react'

function QuestionForm() {
    const [inputValue, setInputValue] = useState('Posez votre question ici')
    return (
        <div>
            <textarea
                value={inputValue}
                onChange={(e) => setInputValue(e.target.value)}
            />
        </div>
    )
}

export default QuestionForm

Ici, je passe une fonction en callback à onChange pour qu'elle sauvegarde dans mon state local la valeur de mon input. J'accède à la valeur tapée dans l'input avec e.targer.value.

inputValue a maintenant accès au contenu de mon input à tout moment. Je peux donc créer un bouton qui déclenche une alerte qui affiche le contenu de mon input, comme ici :

<div>
    <textarea
        value={inputValue}
        onChange={(e) => setInputValue(e.target.value)}
    />
    <button onClick={() => alert(inputValue)}>Alertez moi 🚨</button>
</div>

Et ça marche bien !

Comprenez les avantages des formulaires contrôlés :

Eh bien, cela permet d'interagir directement avec la donnée renseignée par l'utilisateur. Vous pouvez donc afficher un message d'erreur si la donnée n'est pas valide, ou bien la filtrer en interceptant une mauvaise valeur.

Si nous décidons qu'il n'est pas autorisé d'utiliser la lettre "f" (bon oui, c'est un peu bizarre), eh bien nous pouvons déclarer une variable :

const isInputError = inputValue.includes('f')

et afficher ou non un message d'erreur en fonction de ce booléen :

{isInputError && (
    <div>🔥 Vous n'avez pas le droit d'utiliser la lettre "f" ici.</div>
)}

De la même manière, nous pouvons intercepter une mauvaise valeur entrée par l'utilisateur. Pour cela, il faut déclarer une fonction intermédiaire :

function checkValue(value) {
    if (!value.includes('f')) {
        setInputValue(value)
    }
}

et on aplique la modification dans notre fonction callback :

onChange={(e) => checkValue(e.target.value)}

Ici, vous aure be.au marteler votre touche 'f' autant de fois que vous voudrez, la valeur ne s'inscrira pas dans votre input.

Vous ne vous en rendez peut-être pas compte pour l'instant, mais ça vous laisse une très grande liberté quant aux validations que vous voulez créer; et en tant que développeur, c'est vraiment très puissant.

Quand utiliser le composant contrôlé et quand utiliser sa version non contrôlée ? Eh bien cela dépen des cas. À vous de voir selon vos contraintes. Quand vous avez un composant rapide à faire, qui n'intègre aucuene complexité, un input non contrôlé peut faire l'affaire. À l'inverse, si vous avez des vérifications à faire, il vaudra sûrement mieux passer par un composant contrôlé. Pour ma part, j'ai vu beaucoup plus de composants contrôlés dans les codebases que j'ai pu voir.

Sachez qu'il existe également des bibliothèqyes qui vous permettent de gérer les formulaires et leur validation aussi proprement que possible, par exemple le très bon outil react-hook-form.

Et voilà, vous êtes maintenant équipé pour interagir avec vos utilisateurs grâce aux événements et aux formulaires.

Découvrez les stateful components :

Le state local est présent à l'intérieur d'un composant et garde sa valeur, même si l'application le re-render. On peut alors dire qu'il stateful.

Dans Cart.js, nous allons uniquement permettre aux utilisateurs d'ajouter des monsteras dans leur panier, supprimons donc tout le reste.

Commençons par importer useState avec :

import { useState } from 'react'

Puis, on peut créer un state cart. Avec useState, nous devons déclarer en même temps une fonction pour mettre à jour ce state (updateCart), et lui attribuer une valeur initiale, qui sera ici de 0 :

const [cart, updateCart] = useState(0)

Je vais maintenant pouvoir ajouter un bouton dans mon panier qui permet d'ajouter un monstera avec la fonction que nous venons de déclarer. Ce qui me donne dans Cart.js :

function Cart() {
    const monsteraPrice = 8
    const [cart, updateCart] = useState(0)

    return (
        <div className='lmj-cart'>
            <h2>Panier</h2>
            <div>
                Monstera : {monsteraPrice}€
                <button onClick={() => updateCart(cart + 1)}>
                    Ajouter
                </button>
            </div>
            <h3>Total : {monsteraPrice * cart}€</h3>
        </div>
    )
}

Maintenant, si on clique sur "Ajouter", le montant total est modifié en fonction du nombre d'éléments sauvegardés dans le state du panier. Lorsqu'un state est modifié, alors l'affichage du composant est rafraichit et la valeur affichée est actualisée, on dit que le composant est re-render.

Bienvenue dans la magie du state ! Notre composant Cart est maintenant devenu un stateful component, grâce à useState.

Concrètement, cela veut dire que le composant Cart peut être re-render autant de fois que nécessaire, mais la valeur du panier sera préservée.

Familiarisez-vous avec useState :

useState est un hook qui permet d'ajouter le state local React à des composants fonctions.

Un hook est une fonction qui permet de "se brancher" (to hook up) sur des fonctionnalités React. On peut d'ailleurs les importer directement depuis React. Après useState, nous verrons un autre hook : useEffect. Il existe d'autres hooks.

Nous l'avons déjà utilisé, mais je vous le remets ici :

const [cart, updateCart] = useState(0)

Investiguons comment est construit notre state cart.

Comprenez les crochets :

Tout d'abord, les crochets []. Si cette syntaxe peut vous paraître un peu particulière, il s'agit en fait de la même pratique que nous avions vue précédemment : la déstructuration. Sauf qu'ici, ça s'appelle la décomposition parce qu'il s'agit d'un tableau et non d'un objet.

useState nous renvoie une paire de valeurs dans un tableau de 2 éléments, que nous récupérons dans les variables cart et updateCart dans notre exemple. Le premier élément est la valeur actuelle, et le deuxième est une fonction qui permet de la modifier.

Sans la décomposition, nous aurions aussi pu faire :

const cartState = useState(0)
const cart = cartState[0]
const updateCart = cartState[1]

Dans un tableau qu'on décompose, nous pouvons librement nommer nos variables. J'aurais tout aussi bien pu faire :

const [coucou, cavabien] = useState(0)

Initialisez votre state :

Intéressons-nous maintenant au paramètre passé entre parenthèses à useState : useState(0).

Comme je vous l'ai dit, il correspond à l'état initial de notre state. Cet état initial peut être un nombre comme ici, une string, un booléen, un tableau ou encore un objet avec plusieurs propriétés.

Important

Il est important de préciser une valeur initiale dans votre state. Sinon, elle sera undefined par défaut, et ce n'est pas un comportement souhaitable : plus vous serez explicite, mieux votre application s'en portera !

Créez plusieurs variables d'état :

Nous allons encore améliorer notre panier. Cette fois-ci, je veux pouvoir choisir de l'afficher ou de le cacher. Pour ça, nous allons utiliser une variable d'état dans notre composant Cart.

S'il y en a déjà une, comment en créer une autre ? Pas de panique. Il y a plusieurs moyens de faire. Mais le plus simple est d'utiliser plusieurs variables d'état.

Dans notre cas, il suffit de créer une variable isOpen associée avec la fonction setIsOpen, et de l'initialiser à false :

const [isOpen, setIsOpen] = useState(false)

Pour pouvoir interagir, on crée ensuite :

  • un bouton pour ouvrir le panier qui sera le seul composant retourné par Cart si le panier est fermé;

  • et un bouton pour fermer le panier.

Cela nous donne le code suivant :

function Cart() {
    const monsteraPrice = 8
    const [cart, updateCart] = useState(0)
    const [isOpen, setIsOpen] = useState(false)

    return isOpen ? (
        <div className='lmj-cart'>
            <button onClick={() => setIsOpen(false)}>Fermer</button>
            <h2>Panier<h2>
            <div>
                Monstera : {monsteraPrice}€
                <button onClick={() => updateCart(cart + 1)}>
                    Ajouter
                </button>
            </div>
            <h3>Total : {monsteraPrice * cart}€</h3>
        </div>
    ) : (
        <button onClick={() => setIsOpen(true)}>Ouvrir le Panier</button>
    )
}

export default Cart

En ajoutant un peu de style, j'ai donc :

Le panier du site de la maison jungle

Vous voyez ? Pas de soucis à créer plusieurs variables d'état dans un même composant !

C'est bien beau, nous avons nos composants avec leur state local. Notre panier permet d'ajouter des monsteras, et le total du panier se calcule en fonction.

Mais comment faire pour changer le comportement d'un composant en fonction du state d'un autre composant ? Par exemple, si je veux enfin ajouter un lien entre mon Cart et mon composant ShoppingList. Je peux créer un bouton "Ajouter au panier" dans chaque PlantItem... Mais comment faire pour venir compléter mon panier en fonction ?

Faites remonter l'état et mettez-le à jour depuis vos composants enfants :

Comme son nom l'indique, un state local... est local. Ni les parents, ni les enfants ne peuvent manipuler le state local d'un composant (ils n'en ont pas la possibilité technique).

Pour partager un élément d'état entre plusieurs composants, il faudra faire remonter ces données vers le state local du plus proche composant qui est un parent commun, et y garder le state. À partir de là, il sera possible de :

  • Faire redescendre ces infos avec des props jusqu'aux composants qui en ont besoin.

  • Faire "remonter" les demandes d'update toujours dans les props. Pour cela, on peut utiliser la fonction de mise à jour du state récupérée dans useState, en la passant en props aux composants qui en ont besoin.

Remonter les mises à jour aux parents dans les props
Information

Vous vous demandez peut-être quelles sont les bonnes pratiques : où mettre le state ? Dans le composant parent, ou le composant enfant ? Eh bien, dans la pratique... ça dépend totalement. Il est considéré comme plus propre de garder la logique au maximum dans les composants parents, et que les enfants ne servent qu'à afficher les éléments en props. Mais dans de nombreux cas, il est bien mieux de garder le state dans le composant enfant. Vous apprendrez à le voir avec le temps et l'expérience.

Attaquons-nous donc à notre exemple.

Je commence à faire remonter cart dans App.js :

function App() {
    const [cart, updateCart]  useState(0)

    return (
        <div>
            <Banner>
                <img src={logo} alt='La maison jungle' className='lmj-logo' />
                <h1 className='lmj-title'>La maison jungle</h1>
            </Banner>
            <div className='lmj-layout-inner'>
                <Cart cart={cart} updateCart={updateCart} />
                <ShoppingList cart={cart} updateCart={updateCart} />
            </div>
        </div>
    )
}

export default App

Et toujours dans App.js dans le JSX, je passe cart ainsi que updateCart en props :

<Cart cart={cart} updateCart={updateCart} />

... que je récupère dans Cart.js. Vous vous souvenez de la déstructuration ? Ça nous permet de récupérer notre prop en une ligne.

J'en profite pour supprimer mon bouton "Ajouter" dans Cart.js.

On a donc un panier un peu vide :

function Cart({ cart, updateCart }) {
    const monsteraPrice = 8
    const [isOpen, setIsOpen] = useState(true)

    return isOpen ? (
        <div className='lmj-cart'>
            <button
                className='lmj-cart-toggle-button'
                onClick={() => setIsOpen(false)}
            >
                Fermer
            </button>
            <h2>Panier<h2>
            <h3>Total : {monsteraPrice * cart}€</h3>
            <button onClick={() =>updateCart(0)}>Vider le panier</button>
        </div>
    ) : (
        <div className='lmj-cart-closed'>
            <button
                className='lmj-cart-toggle-button'
                onClick={() => setIsOpen(true)}
            >
                Ouvrir le panier
            </button>
        </div>
    )
}

export default Cart

Du côté de ShoppingList, je lui passe updateCart. Je le récupère ensuite dans ShoppingList.js.

Je change ensuite ma liste de plantes pour avoir (toujours dans ShoppingList.js) :

function ShoppingList({ cart, updateCart }) {
    // Petite précision : categories permet de récupérer toutes les catégories uniques de plantes.

    const categories = plantList.renduce(
        (acc, elem) =>
            acc.includes(elem.category) ? acc : acc.concat(elem.category),
            []
    )

    return (
        <div className='lmj-shopping-list'>
            <ul>
                {categories.amp((cat) => (
                    <li key={cat}>{cat}</li>
                ))}
            </ul>
            <ul class='lmj-plant-list'>
                {plantList.map(({ id, cover, name, water, light }) => (
                    <div key={id}>
                        <PlantItem cover={cover} name={name} water={water} light={light} />
                        <button onClick={() => updateCart(cart + 1)}>Ajouter</button>
                    </div>
                ))}
            </ul>
        </div>
    )
}

export default ShoppingList

Et voilà ! Maintenant, vous pouvez updater votre panier directement en cliquant sur un bouton lié à chaque plante.

Vous voyez, ce n'est pas si compliqué, il a suffit de :

  • faire remonter notre state;

  • faire descendre le contenu de notre state et la fonction pour l'updater;

  • déclencher la mise à jour de notre state avec une interaction utilisateur (ici le clic sur le bouton).

Information

Ici, nous sommes sur une petite application : il n'y a qu'une seule page et nous partageons le state directement entre parents et enfants. Mais ça peut vite devenir le bazar pour une plus grosse application. C'est pourquoi la notion de state management va beaucoup plus loin. Il existe des outils dédiés au State Management tels que Flux, Redux ou des solutions natives comme React Context.

Nous avons vu comment partager des éléments du state entre plusieurs composants. Nous allons maintenant adapter notre application pour que le panier se comporte de manière un peu plus réaliste.

L'idée ici est que notre state stocke quels types de plantes ont été ajoutés, en quelle quantité, et de mettre à jour le montnat total en fonction du prix.

Première étape : ajouter le prix à chaque plante dans le fichier plantList.js.

Dans le fichier App.js, on modifie le state cart pour que ce soit un tableau comme ceci :

const [cart, updateCart] = useState([])

Troisième étape : on modifie le fichier Cart.js pour itérer sur les cart comme ceci :

import { useState } from 'react'
import '../styles/Cart.css'

function Cart({ cart, updateCart }) {
    const [isOpen, setIsOpen] = useState(true)
    const total = cart.reduce(
        (acc, plantType) => acc + plantType.amount * plantType.price,
        0
    )
    return isOpen ? (
        <div className='lmj-cart'>
        <button
            className='lmj-cart-toggle-button'
            onClick={() => setIsOpen(false)}
        >
            Fermer
        </button>
        <h2>Panier</h2>
        {cart.map(({ name, price, amount}, index) => (
            <div key={`${name}-${index}`}>
                {name} {price}€ x {amount}
            </div>
        ))}

        <h3>Total : {total}€</h3>
        <button onClick={() => updateCart([])}>Vider le panier</button>
    ) : (
        <div className='lmj-cart-closed'>
            <button
                className='lmj-cart-toggle-button'
                onClick={() => setIsOpen(true)}
            >
                Ouvrir le panier
            </button>
        </div>
    )
}

export default Cart

Enfin, dernière étape : on fait une modification dans le fichier ShoppingList.js pour ajouter la fonction addToCart(...) comme ci-dessous :

import '../styles/ShoppingList.css'

function ShoppingList({ cart, updateCart }) {
    
    const categories = plantList.renduce(
        (acc, plant) =>
            acc.includes(plant.category) ? acc : acc.concat(plant.category),
            []
    )

    function addToCart(name, price) {
        const currentPlantAdded = cart.find((plant) => plant.name === name)
        if (currentPlantAdded) {
            const cartFilteredCurrentPlant = cart.filter(
                (plant) => plant.name !== name
            )
            updateCart([
                ...cartFilteredCurrentPlant = cart.filter(
                    (plant) => plant.name !== name
                )
                updateCart([
                    ...cartFilteredCurrentPlant,
                    { name, price, amount: currentPlantAdded.amount + 1 }
                ])
            ])
        } else {
            updateCart([...cart, { name, price, amount: 1 }])
        }
    }

    return (
        <div className='lmj-shopping-list'>
            <ul>
                {categories.amp((cat) => (
                    <li key={cat}>{cat}</li>
                ))}
            </ul>
            <ul class='lmj-plant-list'>
                {plantList.map(({ id, cover, name, water, light }) => (
                    <div key={id}>
                        <PlantItem cover={cover} name={name} water={water} light={light} />
                        <button onClick={() => addToCart(name, price)}>Ajouter</button>
                    </div>
                ))}
            </ul>
        </div>
    )
}

export default ShoppingList

Notre panier fait maintenant une liste des articles sélectionnés, et met à jour le total en fonction des plantes sélectionnées et de leur prix.

Information

Est-ce que vous avez remarqué que lorsqu'on change le state, on crée un nouvel objet avec le spread operator ? C'est normal : le state est immutable, c'est-à-dire qu'il ne faut pas le modifier directement.

Déclenchez des effets avec useEffect :

Est-ce que je vous ai déjà parlé du render (rendu) d'une application React ?

Dès qu'une modification intervient dans une prop ou le state, le composant concerné et ses enfants sont re-render.

Mais comment faire si on on veut effectuer une action qui ne fait pas partie du return ? Qui intervient après que React a mis à jour le DOM ? Par exemple, si vous voulez déclencher une alerte à chaque fois que votre panier est mis à jour ? Ou bien même pour sauvegarder ce panier à chaque mise à jour ?

Eh bien, ces types d'actions s'appellent des effets de bord, et pour cela, nous avons useEffect. Ils nous permettent d'effectuer une action à un moment donné du cycle de vie de nos composants.

Disons que je veux créer une alert lorsque j'ajoute une plante à mon panier, et que cette alerte affiche le montant total du panier.

Pour ça, une petite ligne de code suffit dans Cart.js :

alert(`J'aurai ${total}€ à payer`)

On la met donc directement dans notre composant, avant le return. Mais, quand je clique, ça bloque mon code et ma valeur ne s'affiche qu'une fois que j'ai cliqué sur "OK" !

À la place, on va utiliser useEffect.

Importez-le comme nous l'avons fait avec useState dans Cart.js :

import { useState, useEffect } from 'react'

et utilisez ce snippet à la place (toujours dans Cart.js) :

useEffect(() => {
    alert(`J'aurai ${total}€ à payer`)
})

Ce qui nous donne pour Cart.js :

import { useState, useEffect } from 'react'
import '../styles/Cart.css'

function Cart({ cart, updateCart }) {
    const [isOpen, setIsOpen] = useState(true)
    const total = cart.reduce(
        (acc, plantType) => acc + plantType.amount * plantType.price,
        0
    )

    useEffect(() => {
        alert(`J'aurai ${total}€ à payer`)
    })

    return isOpen ? (
        <div className='lmj-cart'>
        <button
            className='lmj-cart-toggle-button'
            onClick={() => setIsOpen(false)}
        >
            Fermer
        </button>
        {cart.length > 0 ? (
            <h2>Panier</h2>
            {cart.map(({ name, price, amount}, index) => (
                <div key={`${name}-${index}`}>
                    {name} {price}€ x {amount}
                </div>
            ))}

            <h3>Total : {total}€</h3>
            <button onClick={() => updateCart([])}>Vider le panier</button>            
        ) : (
            <div>Votre panier est vide</div>
        )}
    ) : (
        <div className='lmj-cart-closed'>
            <button
                className='lmj-cart-toggle-button'
                onClick={() => setIsOpen(true)}
            >
                Ouvrir le panier
            </button>
        </div>
    )
}

export default Cart

Et voilà ! Tout se passe comme espéré, pour la simple et bonne raison que useEffect nous permet d'effectuer notre effet une fois le rendu du composant terminé. Et comme useEffect est directement dans notre composant, nous avons directement accès à notre state, à nos variables, nos props, magique n'est-ce pas ?

Quand je ferme mon, mon alerte se déclenche aussi ! Eh bien c'est normal : je vous ai dit que useEffect se déclenche après le rendu. Eh bien il se déclenche après CHAQUE rendu du composant. Sauf si vous...

Précisez quand déclencher une effect avec le tableau de dépendances :

Pour décider précisément quand on veut déclencher un effet, on peut utoliser le tableau de dépendances. Il correspond au deuxième paramètre passé à useEffect.

Information

Petit rappel : le premier paramètre passé à useEffect est une fonction.

Cette fonction correspond à l'effet à exécuter. Ici, il s'agit de :

() => {
    alert(`J'aurai ${total}€ à payer`)
}

Le deuxième paramètre de useEffect accepte un tableau noté entre crochets : il s'agit du tableau de dépendances.

Dans notrre cas, si je veux que l'alerte ne s'affiche que lorsque le total de mon panier change, il me suffit de faire :

useEffect(() => {
    alert(`J'aurai ${total}€ à payer`)
}, [total])

Vous pouvez mettre n'imprte quelle variable ici. Si vous voulez afficher l'alerte quand le total change OU quand une nouvelle catégorie est sélectionnée, vous pourriez tout à fait :

  • récupérer la catégorie sélectionnée (en faisant remonter activeCategory et setActiveCategory et en les passant en props);

  • puis mettre [total, activeCategory] dans votre tableau de dépendances.

L'alerte s'affiche bien quand la catégorie change ou bien quand le total change.

Est-ce que l'effet est lancé au tout premier render de mon composant ? Oui, l'alerte s'affiche.

Comment faire pour exécuter un effet uniquement après le premier render de mon composant ? Par exemple, si je veux récupérer des données sur une API ? Eh bien, dans ce cas, il faut renseigner un tableau de dépendances vide :

useEffect(() => {
    alert('Bienvenue dans La maison jungle')
}, [])
Important

À partir du moment où vous utilisez le tableau de dépendances, faites attention à ne pas ouvlier des dépendances, ou bien à ne pas en laisser qui n'ont plus rien à y faire, pour éviter d'exécuter à des moments inopportuns.

Modifiez le titre de votre onglet :

Bon, moi je commence à en avoir un peu marre de toutes ces alertes. J'ai plutôt envie d'utiliser useEffect pour mettre à jour le titre de l'onglet de mon navigateur.

Vous voyez de quoi je parle ?

On va donc utiliser document.title toujours dans Cart.js, comme ici :

useEffect(() => {
    document.title = `LMJ : ${total}€ d'achats`
}, [total])

Et voilà ! Le litre de notre onglet change en fonction du total de notre panier !

useEffect modifie le titre de notre onglet pour la maison jungle.

Maîtrisez les règles de useEffect :

Intégrez les différentes étapes de useEffect :

Repassons sur ce qu'on vient de voir de useEffect :

useEffect(() => {
    console.log(`Cette alerte s'affiche à chaque rendu`)
})
useEffect(() => {
    console.log(`Cette alerte s'affiche au premier rendu`)
}, [])
useEffect(() => {
    console.log(`Cette alerte s'affiche la première fois et quand mon panier est mis à jour`)
}, [cart])

Il est pssoble d'effectier une action quand React démonte le composant en le retirant du DOM. Dans notre App.js, on rajoute le state :

const [isFooterShown, updateIsFooterShown] = useState(true)

{isFooterShown && <Footer cart={cart} />}

Ensuite, dans notre Footer.js, on utilise le useEffect suivant :

useEffect(() => {
    return () =>
        console.log(`Cette alerte s'affiche quand Footer est retiré du DOM`)
})

Cela nous permet d'effectuer un "nettoyage" de notre effet. En effet, il est indispensable de nettoyer certains effets au unmount (démontage) d'un composant pour éviter les fuites de mémoire, typiquement si on utilise setInterval. Nous n'aurons pas besoin de tels effets pour le moment.

Intégrez quelques règles :

Comme je vous l'ai expliqué précédemment, useEffect est un hook, une fonction qui permet de "se brancher" sur la fonctionnalité des effets de React. Mais quelques règles s'appliquent au hook useEffect :

  • Appelez toujours useEffect à la racine de votre composant. Vous ne pouvez pas l'appeler à l'intérieur de boucles, de code conditionnel ou de fonctions imbriquées. Ainsi, vous vous assurez d'éviter des erreurs involontaires.

  • Comme pour useState, useEffect est uniquement accessible dans un composant fonction React. Donc ce n'est pas possible de l'utiliser dans un composant classe, ou dans une simple fonction JavaScript.

Par ailleurs, je vous conseille de séparer les différentes actions effectuées dans différents useEffect. Cela est plutôt une bonne pratique qu'une règle.

Information

Les hooks sont assez récents. Les développeurs React y ont accès depuis début 2019. Avant, il n'était pas possible d'accéder au state ni aux effets depuis des composants fonctions. Si on voulait utiliser un effet de bord ou l'état local; il fallait forcément passer par un class component. Vous pouvez vous estimer heureux d'avoir accès aux hooks dès le début de votre apprentissage de React, car ils représentent plusieurs avanatages pour les développeurs et développeuses :

  • ils nécessitent d'écire moins de code;

  • ils sont donc plus faciles à tester;

  • mais également plus lisibles.

Créez une application React complète :

Installez votre app avec Create React App :

Pour initialiser notre application, nous allons utiliser Create React App (CRA).

Dans votre terminal, placez-vous dans le dossier où vous souhaitez créer votre projet et faites la commande suivante :

npx create-react-app shiny-agency

Architecturez votre projet par modules :

Maintenant que nous avons la base de notre projet, nous allons pouvoir nous décider sur son architure.

Pas de panique ! Ici, je ne vais pas vous parler de patterns complexes. L'idée est juste de suivre des règles logiques et suffisamment claires pour que vous sachiez où placer les fichiers que vous créez, et où retrouver ceux dont vous avez besoin dans votre codebase. D'ailleurs, dans la documentation React sur la structure de fichiers, React laisse la liberté aux développeurs quant à la structure de leur projet.

Information

Lorsque vous utilisez des frameworks tels que Next ou Gatsby une structure de fichiers toute faite vous sera déjà proposée. je vous conseille d'y jeter un oeil si vous avez l'occassion !

Précédemment, nous avions organisé le code de sorte à regrouper les fichiers par type : chaque fiche correspondait à un composant situé dans le dossier /components. Les fichiers CSS, quant à eux, se trouvaient dans le dossier /style, etc.

Ici, nous allons organiser nos fichiers en suivant la même logique, sauf qu'il faudra ajouter un dossier pour les "pages", qui regroupera les composants vers lesquels chacune de nos routes renverra (pas de panique si vous ne savez pas ce qu'est une route).

Commençons donc par créer un dossier /components et un dossier pages dans src. Dans /pages, on créé un dossier Home et l'on y insère App.js, qu'on renomme en index.jsx. On peut ensuite supprimer tous les fichiers que nous n'utiliserons pas.

Ce qui nous donne...

├── README.md
├── node_modules
...
├── package.json
├── public
...
├── src
│   ├── index.js
│   ├── components
│   └── pages
│       └── Home
│            └── index.jsx 
└── yarn.lock

Ici, pas d'obligation d'utiliser l'extension .jsx. Votre fichier React fonctionnera très bien aussi avec une extension .js, mais puisqu'on peut l'utiliser, autant être explicite, n'est-ce pas ? Ainsi, vous voyez en un coup d'oeil quand votre fichier du React, et quand il n'en contient pas.

Ce qui est important pour nous est de pouvoir retrouver rapidement nos fichiers. Pour les architectures, il n'existe pas de solution parfaite à utiliser dans tous les cas. Nous devons donc essayer de trouver une organisation qui convienne. Ici, nous aurons relativement peu de fichiers, donc une structure par type devrait bien fonctionner.

On n'oublie pas de mettre à jour les paths des imports, par exemple dans index.js à la racine de src :

import Home from './pages/Home/';

Les approches sont multiples. Lorsque vous travaillez sur une application complexe, avec plusieurs grosses fonctionnalités, vous pouvez envisager un découpage selon les fonctionnalités, par exemple. Vous trouverez un petit exemple de ce type d'organisation dans la documentation React.

Un autre type d'organisation qui fonctionne bien ces dernières années est la création et l'organisation des composants selon les principes de l'atom design, qui facilite la collaboration avec les designers. mais dans notre cas, l'application n'a pas vocation à être très grande, et l'utilisation d'une telle structure pourrait paraître superflue. C'est pourquoi nous avons choisi d'organiser le projet de ce cours par type de fichier, afin de s'approcher au maximum de ce qui aurait été fait en entreprise.

Information

Dans notre codebase, nous allons organiser nos fichiers de sorte à avoir /NomDuComposant/index.jsx. Cette pproche présente des avantages (notamment de ne pas avoir à nous répéter lorsqu'on importe nos fichiers), mais peut aussi paraître un peu désorganisée dans tous vos onglets d'IDE. Comme pour tout en code, il existe plusieurs manières de faire; à vous de voir ce qui vous convient, et de vous adapter lorsque vous travaillez sur une codebase qui est un peu différente. Si ce sujet vous intéresse, vous pourrez en apprendre davantage sur l'organisation et le nommage des fichiers dans cet article (en anglais).

Et voilà pour notre architecture ! Penchons-nous maintenant sur quelques outils qui nous permettent d'écrire du code plus proprement et d'éviter des erreurs communes.

Exploitez les outils ESLint et Prettier :

Pour ce qui est des erreurs de code, ou tout simpelment de la mise en forme, vous le savez sûremnt déjà, JavaScript est très souple : pas de compilation stricte qui vous signale vos erreurs. Heureusement, il existe des outils qui permettent d"crire du code plus propre !

Vous vous posez la question suivante : Écrire du code plus proprement ? Pourquoi prendre du temps à configurer des outils, alors que je m'en sors très bien comme ça ? Eh bien, vous vous en sortez bien actuellement, sur une codebase plutôt petite et en travaillant seul. Mais imaginez que vous travaillez dans une équipe, dans laquelle chacun a son propre style de code (mettre des points-virgules ou non, les règles d'indentation, etc.). Croyez-moi, ce n'est vraiment pas efficace.

Dans notre cas, nous allons nous intéresser à ESLint, qui va vous signaler des erreurs dans le code (aussi bien des erreurs de style que des erreurs basiques de code qui peuvent conduire à des bugs), et Prettier, qui va le formater.

Sécurisez votre code avec ESLint et son extension :

Commençons donc par ESLint.

Si vous regardez votre package.json, vous verrez qu'ESLint fait déjà partie des outils préconfigurés par Create React App. Cet outil permet de vous signaler des erreurs de code - si vous utilisez une variable qui n'a jamais été déclarée, par exemple.

Testons dès maintenant. Dans Home/index.jsx, je rajoute une ligne de code en faisant un console.log d'une variable non déclarée avant le return :

console.log(ceciEstUneErreur)

Rien de spécial ne s'affiche. Pourtant, la variable n'est pas déclarée, donc c'est bien une erreur.

... Mais si vous n'avez pas encore installé l'extension ESLint dans votre éditeur de code (IDE), rien ne se passe.

Installons donc l'extension. Pour ma part, j'utilise VS Code qui me permet d'installer une extension directement dans l'onglet "Extensions" (cela dépend de l'IDE que vous utilisez). Une fois l'extension installée, votre éditeur de code devrait souligne votre erreur, comme ici.

La voilà notre erreur !

Et voilà, la configuration est prête. On peut configurer manuellement ESLint en créant le fichier .eslintrc avec le contenu suivant :

{
    "extends": ["react-app"],
    "rules": {
        "no-console": "error"
    }
}

On peut également utiliser la configuration ESLint de Airbnb qui peut être installé via npx.

Formatez votre code avec Prettier :

Alors qu'ESLint vous permet de relever des erreurs de syntaxe, Prettier est la référence pour formater votre code. Contrairement à ESLint, Prettier n'est pas installé de base avec Create-React-App.

Pour vous donner une idée de la puissance de cet outil, regardez un peu ce GIF :

Bien pratique de tout formater en un clic, n'est-ce pas ?

Pas mal, n'est-ce pas ? Alors, lançons-nous dans la config ! Pour commencer, vous pouvez installer la library (bibliothèque) dans votre terminal avec yarn add -D prettier.

À partir de là, l'outil est installé, mais nous devons le lancer manuellement - alors que nous voulons que Prettier fasse tout à notre place sans même avoir à y penser ! La manière la plus simple est d'installer l'extension de Prettier directement dans les extensions de votre IDE.

Pour ma part, dans VSCode, j'installe l'extension.

L'extension Prettier dans VSCode

Il vous suffit d'aller dans les paramètres de votre IDE (Code > Preferences > Settings dans VSCode pour Windows) pour activer la commande formatOnSave. Elle permettra de formater le fichier à chaque fois que vous sauvegardez.

Vous pouvez également aller dans la barre de recherche de votre IDE, taper "format" et activer formatOnSave :

Le parametrage à activer dans VSCode pour formater en sauvegardant

Mais attendez, on n'a pas encore fini ! Nous allons aussi activer l'option qui permet d'éviter de modifier tous les fichiers à tort et à travers.

Tapez require config ey descendez sur le paramètre correspondant à Prettier. Activez le paramètre comme ci-dessous :

Activez cette option dans vos paramètres pour formater uniquement les fichiers qui ont une config Prettier.

Cela permet de préciser à VSCode de formater le fichier uniquement quand une configuration est présente dans le projet pour Prettier.

Donc à nous d'en créer une !

À la racine de votre projet, créez un fichier .prettierrc dans lequel vous allez pouvoir préciser quelques règles. Vous trouverez l'ensemble de ces règles dans la documentation de Prettier. typiquement, si vous voulez supprimer tous les points-virgules, vous faites :

{
    "semi": false
}

Et voilà, si vous retournez dans votre fichier Home/index.jsx, vous aurez bien du code sans les points-virgules !

Félicitations ! Vous venez de configurer avec succès ESLint et Prettier avec leurs extensions, vous n'aurez plus à vous soucier du formatage de votre code ou bien de faire des erreurs d'inattention !

Transformez votre application en Single Page Application avec React Router :

Comprenez le principe de SPA :

Prenons notre machine à voyager dans le temps et retournons aux débuts du Web.

À cette époque, l'immense majorité des sites consistaient en un groupe de pages, envoyées par le serveur, qui s'affichaient en fonction de la navigation. Pour chaque interaction, telle que l'envoi d'un formulaire, la page entière devait être rechargée.

Mais au début des abbées 2000, le concept de Single Page Application (SPA) commence à émerger. Les idées principales derrière ce concept sont les suivantes :

  • les utilisateurs ne chargent une page Web qu'une seule fois (le fameux index.html).

  • Au lieu de récupérer toute la page avec une page avec une requête HTTP, on les récupère de manière distincte, petite partie par petite partie, ce qui permet à l'utilusateur d'interagir de manière beaucoup plus dynamique.

  • L'utilisateur peut naviguer entre plusieurs pages et JavaScript (et dans notre cas, React) gère l'affichage de nouvelles pages au sein du même domaine, sans qu'un rafraîchissement complet de la page soit nécessaire.

L'affichage du site Web classique en haut est moins dynamique que celui de la SPA en bas.

Toutes les applications ne sont pas nécessairement des SPA. Lorsque vous codez votre site en Single Page Application, il faut être conscient de certains inconvénients : vos utilisateurs doivent notamment impérativement avoir JavaScript pour que votre site fonctionne, ou encore le Search Engine Optimisation (SEO, l'optimisation de l'indexation de votre site par les moteurs de recherche) est plus laborieux pour les Single Page Applications.

Information

Je vous ai déjà parlé de Gatsby ou bien Next.js. Il s'agit de frameworks basés sur React qui permettent de générer notre application côté serveuur et donc de faire du Server Side Rendering (SSR). C'est-à-dire qu'ils génèrent le HTML depuis React côté serveur et puis l'envoient avec chaque page déjà générée au client. Pour ce qui est de leur routing, ils mettent leur propre solution à disposition. Elle se comporte comme du routing de SPA au niveau de l'expérience utilisateur, mais qui est un peu plus complexe qu'il n'y paraît.

Les projets que nous créons avec Create React App ne peuvent pas encore être considérés comme des Single Page Applications : il leur manque une solution de routing.

Découvrez React Router :

Contrairement aux frameworks comme Angular, React ne nous fournit pas directement une solution pour gérer les routes de notre application. Pas de panique, comme pour quasiment tout en React, l'écosystème a vite comblé ce besoin. Il existe donc plusieurs solutions de routing. Cela à laquelle nous allons nous intéresser dans la suite est React Router (le nom est plutôt bien trouvé, n'est-ce pas ?).

Comme nous pouvons le voir dans la documentation React Router, une route permet d'afficher des composants de manière conditionnelle si le path (chemin) de l'URL correspond au path de la route.

On lui passe en prop le path auquel la route correspond et elle se charge d'afficher les children qui lui sont passés.

Cette bibliothèqye, créée par Remix, met à votre disposition tous les outils nécessaires pour gérer la navigation dans votre appliction côté client.

Alors, partons à la découverte de React Router.

Créez votre prmeier fichier de routing :

Important

React Router est très régulièrement mis à jour. La version utilisée pour faire ce cours est la V6. Pour suivre ce cours, vous pourrez préciser la version que vous souhaitez installer directement dans la ligne de commande pour installer le package. Ici nous installerons la version 6.10.0.

N'hésitez pas à consulter le blog de React Router pour en apprendre davantage sur les différentes versions et leurs actualités.

Nous allons commencer par installer la bibliothèque avec yarn add react-router-dom@6.10.0. Si vous voulez en apprendre davantage sur la configuration, n'hésitez pas à jeter un oeil à la documentation de React Router.

React Router est maintenant prêt à être utilisé !

Actuellement, nous n'avons qu'une seule fonctionnalité avec Home. Créons dès maintenant un nouveau composant pour le questionnaire.

Pour cela, on crée un dossier, on crée un dossier SUrvey.jsx dans pages. Pour le moment, gardons un composant très simple :

function Survey() {
    return (
        <div>
            <h1>Questionnaire</h1>
        </div>
    )
}

export default Survey

Votre mission, si vous l'acceptez, est de pouvoir naviguer entre la page d'accueil - Home - et le questionnaire - Survey.

Vous vous en doutez sûrement : nous allons utiliser React Router et ses composants BrowserRouter, Routes et Route !

Dans l'exemple ci-dessous, on renomme BrowserRouter en Router pour une lecture plus simple. Il servira à stocker et à s'abandonner au changement de l'URL de la page courante (celle qu'on retrouve dans la barre d'URL).

Ensuite, nous ajoutons le composant Routes qui va servir à sélectionnner le composant enfant correspondant à la location.

Finalement, le composant le plus complexe, le composant Route. Ce composant prend de base plusieurs paramètres dont à minima :

  • path qui contient l'URL dans notre navigateur qui dirigera vers le composant;

  • element qui va permettre de sélectionner le composant à afficher.

Le fichier index.jsx à la racine de votre projet se transforme donc de cette manière :

import React from 'react'
import ReactDOM from 'react-dom'
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'
import Home from './pages/Home/'

ReactDom.render(
    <React.StrictMode>
        <Router>
            <Routes>
                <Route path="/" element={<Home />} />
            </Routes>
        </Router>
    </React.StrictMode>,
    document.getElementById('root')
)

L'idée est maintenant de mettre dans notre router toutes les routes qui seront accessibles.

Il faudra afficher le bon composant pour cette URL.

Créons donc une route pour la page d'accueil et pour notre questionnaire.

import React from 'react'
import ReactDOM from 'react-dom'
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'
import Home from './pages/Home/'
import Survey from './pages/Survey/'

ReactDom.render(
    <React.StrictMode>
        <Router>
            <Routes>
                <Route path="/" element={<Home />} />
                <Route path="/survey" element={<Survey />} />
            </Routes>
        </Router>
    </React.StrictMode>,
    document.getElementById('root')
)

Si vous allez sur l'URL http://localhost:3000/, on a bien la page d'accueil qui s'affiche. C'est la mêem chose si vous vous mettez http://localhost:3000/survey dans la barre d'URL.

Félicitations ! Tout fonctionne bien comme prévu !

Mais bon... Ce n'est pas vraiment pratique de devoir taper toutes nos URL à la main dans la barre du navigateur pour changer de page.

Profitons-en pour créer notre header, avec les liens vers les différentes pages de notre application.

Dans notre dossier /components, on crée donc un nouveau dossier /Header avec un fichier index.jsx à l'intérieur, ce qui nous donne /components/Header/index.jsx :

import { Link } from 'react-router-dom'

function Header() {
    return (
        <nav>
            <Link to="/">Accueil</Link>
            <Link to="/survey">Questionnaire</Link>
        </nav>
    )
}

export default Header

Ici, j'utilise Link, qui nous vient de React Router et se comporte comme une balise anchor. Il est donc très important de l'utiliser lorsque vous souhaitez naviguer pour l'accessibilité de votre application (et non utoliser des redirections déclenchées par des onClick).

Utilisons maintenant Header dans index.jsx à la racine de notre projet :

import React from 'react'
import ReactDOM from 'react-dom'
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'
import Home from './pages/Home/'
import Survey from './pages/Survey/'
import Header from './components/Header'

ReactDom.render(
    <React.StrictMode>
        <Router>
            <Header />
            <Routes>
                <Route path="/" element={<Home />} />
                <Route path="/survey" element={<Survey />} />
            </Routes>
        </Router>
    </React.StrictMode>,
    document.getElementById('root')
)
Information

Ici, j'aurais pu utiliser Header dans Home et dans Survey. Mais, encore mieux, je le place à la base du router. On considère ici que notre Header fait partie du Layout (agencement) de notre application.

Nos routes renvoient bien les bons composants.

Vous avez maintenant la base de votre application avec navigation : félicitations à vous, vous avez fait du bon boulot.

Maintenant que nous avons vu comment mettre en place le routing, j'en profite pour vous montrer comment découper notre router quand nous avons beaucoup de routes à gérer - dans un projet de code plus important, par exemple.

Utilisez les Outlets pour afficher certaines parties de la page :

Dans une application complexe, on peut décider d'afficher certaines parties de la page en fonction de la route que nous avons prise. Imaginons que pour notre questionnaire nous souhaitons afficher des questions différentes si la personne est porteuse de projet ou prestataire freelance.

Information

Nous ne le ferons pas dans notre application, nous faisons juste cet exemple afin que vous avez connaissance de cette possibilité.

Nous commençons donc par modifier notre composant Survey comme ceci :

import { Outlet, Link } from 'react-router'

function Survey() {
    return (
        <div>
            <h1>Questionnaire</h1>
            <Link to="client">Questionnaire Client</Link>
            <Link to="freelance">Questionnaire Freelance</Link>
            <Outlet />
        </div>
    )
}

export default Survey

Ensuite, il nous faut créer nos composants pour les formulaires client et prestataires. Je vais donc créer un dossier ClientForm dans /components. Dans ce dossier, je crée mon fichier index.js avec le code suivant :

function ClientForm() {
    return (
        <div>
            <h2>Questionnaire Client</h2>
        </div>
    )
}

export default ClientForm

Je peux faire la même chose ensuite avec le composant FreelanceForm, puis finalement he viens de modifier mon Router afin d'inclure les composants de ma page Questionnaire de cette manière :

import React from 'react'
import ReactDOM from 'react-dom'
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'
import Home from './pages/Home/'
import Survey from './pages/Survey/'
import Header from './components/Header'
// On ajoute nos composants
import ClientForm from './components/ClientForm'
import FreelanceForm from './coimponents/FreelanceForm'

ReactDom.render(
    <React.StrictMode>
        <Router>
            <Header />
            <Routes>
                <Route path="/" element={<Home />} />
                <Route path="/survey" element={<Survey />}>
                    { /* Nous imbriquons nos composants dans survey */ }
                    <Route path="client" element={<ClientForm />} />
                    <Route path="freelance" element={<FreelanceForm />} />
                </Route>
            </Routes>
        </Router>
    </React.StrictMode>,
    document.getElementById('root')
)

Et voilà, notre affichage de survey dépendra maintenant de notre route :

  • /survey : n'affichera que l'en-tête et les deux liens;

  • /survey/client : ajoutera le composant ClientForm;

  • /survey/freelance : ajoutera le composant ClientForm.

Nos Outlets fonctionnent !
Information

Si vous avez réalisé le code en même temps que moi, je vous conseille de le commit dans une nouvelle branche, car comme énoncé plus haut, nous ne servirons pas des Outlets dans notre projet.

récupérez des paramètres dans vos URL :

La navigation de notre application fonctionne bien, mais comment faire si vous voulez passer des paramètres ? Par exemple, lorsqu'on va faire le questionnaire et que le numéro de chaque question sera récupéré depuis l'URL ? Eh bien, bonne question ! Le router vous permet de récupérer des paramètres; pour cela, il suffit d'écrire votre route comme ici dans le fichier index.jsx à la racine de /src :

<Route path="/survey/:questionNumber element={<Survey />} />

Dans components/Header/index.jsx, mettons donc un numéro de question à la suite :

function Header() {
    return (
        <nav>
            <Link to="/">Accueil</Link>
            <Link to="/survey/42">Questionnaire</Link>
        </nav>
    )
}

Allons maintenant récupérer ce paramètre dans Survey/index.jsx à l'aide du hook useParams, mis à disposition par React Router :

import { useParams } from 'react-router-dom'

function Survey() {
    const { questionNumber } = useParams()

    return (
        <div>
            <h1>Questionnaire</h1>
            <h2>Question {questionNumber}</h2>
        </div>
    )
}

Félicitations à vous ! Vous avez récupéré le numéro de votre question en paramètre.

Créez une route pour les attraper toutes : 404 :

Quelle chance, tout fonctionne comme on le souhaite ! Mais qu'est-ce qui se passe si je commence à taper n'importe quoi dans mon URL ? par exemple, si j'essaie d'accéder au contenu de http://localhost:3000/coucouCommentCaVa ?

Notre header s'affiche, mais rien d'autre... Moi, j'aimerais signaler à l'utilisateur que rien n'existe à cette adresse. Eh bien, ça vous dit quelque chose, les pages d'erreur ? C'est ce que nous allons faire ici : afficher une page 404.

On commence par créer un simple composant Error dans components/Error/index.jsx :

function Error() {
    return (
        <div>
            <h1>Oups ! Cette page n'existe pas</h1>
        </div>
    )
}

export default Error

On retourne maintenant dans notre Router. Afin de gérer les erreurs qui n'existent pas nous ajouterons une route avec un path particulier, le path=* ainsi que toutes les routes qui ne sont pas mentionnées plus haut conduiront à ma page d'erreur.

Dans notre router, on a donc :

import React from 'react'
import ReactDOM from 'react-dom'
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'
import Home from './pages/Home/'
import Survey from './pages/Survey/'
import Header from './components/Header'
import Error from './components/Error/'

ReactDom.render(
    <React.StrictMode>
        <Router>
            <Header />
            <Routes>
                <Route path="/" element={<Home />} />
                <Route path="/survey/:questionNumber" element={<Survey />} />
                <Route path="*" element={<Error />} />
            </Routes>
        </Router>
    </React.StrictMode>,
    document.getElementById('root')
)

Testons dans notre navigateur :

Notre Route permet d'afficher une erreur lorsque l'URL saisie n'existe pas dans le router.
Information

Ici, nos besoins en routing restent limités. Mais comment auriez-vous fait si vous aviez dû créer un système d'authentification ? Eh bien, le router aurait été au coeur de votre login. Vous auriez séparé votre application entre les routes non authentifiées, telles qu'une page d'inscription, de connexion, etc., et les routes authentifiées. À chaque requête sur une page authentifiée, vous auriez dû passer un token secret permettant de vous authentifier à l'API :

  • Si le token est correct, pas de souci, vous récupérez vos données.

  • En cas d'erreur de token, vous recevez une erreur qui a pour conséquence de vous rediriger auutomatiquement côté router de React sur la partie non authentifiée avec Redirect.

Vous pouvez en apprendre plus sur l'authentification avec React React Router dans cet article (en anglais).

Indiquez les types de vos props avec les PropTypes :

Découvrez le typage :

Les PropTypes sont une des méthodes les plus répandues pour sécuriser le type des props que reçoivent vos composants.

En termes de typage, le typage de JavaScript est considéré comme "faible" : JS fonctionne sur du typage dynamique et n'assure pas de type safety. Cela veut dire que lorsqu'on déclare une nouvelle variable, le développeur ou la développeuse ne précise pas de quel type sera la nouvelle variable, le code s'adapte à la volée, et on peut tout à fait changer le type d'une variable.

En JS, je peux totalement faire :

let maVariable = 42
maVariable = "quarante trois"

Alors que dans un certain nombre d'autres langages, ce n'est pas du tout possible.

Typiquement, en C, je dois préciser le type dès la déclaration et il ne change pas. Le typage est alors qualifié de "statique". Ici, maVariable est un integer :

int maVariable;
maVariable = 42; // Pas de souci ici
maVariable = "Quarante trois"; // ce code ne compilera pas !

Les choses sont donc bien plus flexibles en JavaScript.

Mais ne vous réjouissez pas trop vite, cette flexibilité peut aussi causer votre perte. En effet, des bugs sont vite arrivés en JavaScript, surtout lorsque vous collaborez en équipe !

C'est pourquoi il existe des outils pour assurer ses arrières, tels que les PropTypes.

Installez PropTypes :

La bibliothèque PropTypes vous permey de déclarer le type des props qui est attendu lorsque vous les récupérez dans vos composants, et de déclencher un warning si ça ne correspond pas. Bien pratique !

Comme d'habitude, pour installer la bibliothèque PropTypes, il vous suffit de lancer la commande yarn add prop-types.

Profitons-en pour mettre les propTypes en pratique dans notre application Shiny Agency !

Nous allons dès maintenant coder la base des Card dans la page /freelances.

Dans le dossier /components, on vient créer un nouveau dossier /Card dans lequel vous pouvez créer un fichier index.jsx. Vous pouvez y coller le code suivant :

function Card({ label, title, picture }) {
    return (
        <div style={{ display: 'flex', flexDirection: 'column', padding: 15 }}>
            <span>{label}</span>
            <img src={picture} alt="freelance" height={80} width={80} />
            <span>{title}</span>
        </div>
    )
}

export default Card

Ce composant récupère 3 props : label, title et picture. J'ai également ajouté quelques propriétés de style pour que le tout soit plus visible.

Nous allons utiliser les Card dans notre fichier Freelances/index.js. Comme nous n'avons pas encore récupéré les datas que nous afficherons, nous allons créer un tableau d'objets nous-mêmes, qu'on déclare dans /pages/Freelances/index.jsx :

import DefaultPicture from '../../assets/profile.png'

const freelanceProfiles = [
    {
        name: 'Jane Doe',
        jobTitle: 'Devops',
        picture: DefaultPicture,
    },
    {
        name: 'John Doe',
        jobTitle: 'Développeur frontend',
        picture; DefaultPicture,
    },
    {
        name: 'Jeanne Biche',
        jobTitle: 'Développeuse Fullstack',
        picture: DefaultPicture,
    },
]

Ici, on utilise une photo de profil vide standard qu'on a mise dans un dossier /assets. Nous pouvons mapper ce tableau pour afficher le composant Card :

function Freelances() {
    return (
        <div>
            <h1>Freelances</h1>
            {freelanceProfiles.map((profile, index) => (
                <Card
                    key={`${profile.name}-${index}`}
                    label={profile.jobTitle}
                    picture={profile.picture}
                    title={profile.name}
                />
            ))}
        </div>
    )
}

Nous avons tout ce qu'il nous faut ! Sécurisons donc les props de Card avec les propTypes !

Card récupère 3 props, label, title et picture. On va donc importer PropTypes depuis la bibliothèque et utiliser Card.propTypes pour préciser les types de chacune des propriétés.

import PropTupes from 'prop-types'

function Card({ label, title, picture }) {
    return (
        <div style={{ display: 'flex', flexDirection: 'column', padding: 15 }}>
            <span>{label}</span>
            <img src={picture} alt="freelance" height={80} width={80} />
            <span>{title}</span>
        </div>
    )
}

Card.propTypes = {
    label: PropTypes.string,
    title: PropTypes.string,
    picture: PropTypes.string,
}

export default Card

Vous pouvez essayer dès maintenant de passer une prop dont le type n'est pas string, pour voir. Dans freelances/index.jsx :

{freelanceProfiles.map((profile, index) => (
    <Card
        key={`${profile.name}-${index}`}
        label={profile.jobTitle}
        picture={profile.picture}
        title={42}
    />
))}
Ça me renvoie directement une erreur dans la console !
Information

Ici, nous n'avons que des strings, mais il existe bien plus de types supportés par propTypes. Vous pourrez les trouver dans la doc de React.

Bravo à vous, vous venez de sécuriser votre composant Card à l'aide des propTypes.

Mais je ne vous ai pas tout montré : vous pouvez même préciser si une prop est requise ou non !

Exigez une prop :

Il est très simple de préciser qu'une prop est requise pour le bon fonctionnement de l'application. Pour cela, il suffit d'ajouter isRequired à la suite du type déclaré.

Par exemple, toujours pour Card, si on fait :

Card.propTypes = {
    label: PropTypes.string,
    title: PropTypes.string.isRequired,
    picture: PropTypes.string
}

... et qu'on omet de déclarer la prop title, ça nous donne :

Une erreur apparaît dans la console lorsque vous oubliez de préciser une prop qui est requise.

Et voilà, vous avez réussi à "exiger une prop avec .isRequired !

Je voulais vous montrer une dernière chose en lien avec les propTypes : comment définir une prop par défaut.

µ

Définissez des props par défaut :

Bon, précédemment, vous avez déjà vu comment définir par défaut avec la déstructuration. Mais nous allons voir comment le faire de manière plus formelle : nous allons utiliser defaultProps. Dans l'exemple précédent, au lieu de déclencher une erreur pour notre propriété manquante (alors qu'on avait précisé isRequired), nous aurions pu aussi déclarer une propriété par défaut.

Il est possible d'assigner une valerur à title directement dans la déstructuration, comme ici :

function Card({ label, title = 'Mon titre par défaut', picture })

Mais cette syntaxe déclenche malgré tout une erreur de PropType. Dans ce cas, la meilleure manière est d'avoir recours à defaultProps. Juste en dessous de Card.propTypes, nous déclarons un objet Card.defaultProps :

Card.defaultProps = {
    title: 'Mon titre par défaut',
}
Information

Jusqu'à il y a quelques années, prop-types était la solution recommandée pour typer ses props. Mais aujourd'hui, il existe d'autres solutions, telles que TypeScript ou Flow. Si vous voulez voir à quoi ressemble la base d'une application codée avec TypeScript, vous trouverez la branche "typescript" dans le projet "shiny-agency" où l'application est entièrement codée en TS.

Scopez votre CSS avec styled components :

Pour ajouter du style à votre application React, vous avez certainement jusqu'ici utilisé du CSS. Pour gagner du temps de développement sur le CSS nous pouvons même utiliser des bibliothèqyes comme Bootstrap ou encore Tailwind. Cependant, vous est-il déjà arrivé de réutiliser le nom d'une classe CSS dans un autre composant sans faire exprès ? Ou bien encore de trouver du style appliqué à un élément sans comprendre d'où il venait ? Si ça vous est déjà arrivé, vous devez savoir à quel point c'est frustrant !

L'enjeu de scoper notre style aux composants concernés est réel.

Le scope correspond aux parties de notre code qui ont accès à un élément, comme une variable, ou une classe CSS. Il peut être global (comme c'est le cas pour les classes CSS dont je vous parlais il y a quelques instants), ou bien converner une partie spécifique du code.

Pour scoper le style, il existe des solutions, telles que des méthodologies d'architecture de CSS ou bien des outils spécifiques comme Sass (qui requiert un préprocesseur). Mais depuis quelques années, le CSS in JS émerge comme l'une des solutions à notre problème.

Découvrez le CSS in JS :

Comme son nom l'indique, le CSS in JS est généré... avec du JavaScript. Il sera inséré dans le DOM dans un élément <style>.

L'inline style est inséré dans le DOM sur l'attribut style d'un élément spécifique (souvenez-vous, on fait <div style={{ color: 'red' }} />). Par ailleurs, l'inline style ne permetpas d'utiliser les pseudo-selectors. Ce n'est pas pareil pour le CSS in JS, avec lequel on peut utiliser autant de pseudo-selectors que nécessaire.

Mais avec le CSS in JS, on garde l'idée que le style est attaché à un compposant spécifique, directement dans le même fichier. Beaucoup plus simple lorsqu'il faut supprimer ou modifier du style déjà existant, n'est-ce pas ?

Il existe plusieurs solutions de CSS in JS, avec leurs syntaxes propres. Ici nous allons nous intéresser à la bibliothèque styled components.

Commençons dès maintenant par installer la bibliothèque avec yarn add styled-components :

Styled-components s'installe.

Penchons-nous dès maintenant sur le style que nous allons pouvoir créer !

Appliquez la logique styled components :

Dans styled components, la principale chose à comprendre est que tout est composant. Pour voir cela, créons dès maintenant notre premier styled component (styled composant).

Dans Card/index.jsx, créons donc le style pour le label.

On commence par ajouter l'import de styled-components puis nous créons notre composant CardLabel de la manière suivante :

import styled from 'styled-components'

const CardLabel = styled.span``

Et on a réutilisé CardLabel directement dans le JSX :

<CardLabel>{label}</CardLabel>

Pas de panique ! Ici, styled-components utilise des templates literals que vous pouvez voir dans la documentation Mozilla. Vous pouvez écrire votre CSS directement à l'intérieur. Ce qui nous donne :

const CardLabel = styled.span`
    color: #5843e4;
    font-size: 22px;
    font-weight: bold;
`

Profitons-en pour ajouter du style à notre image. Cette fois-ci, toujours dans le même fichier Card/index.jsw, on a :

const CardImage = styled.img`
    height: 80px;
    width: 80px;
    border-radius: 50%;
`

... qu'on utilise dans le code :

<CardImage src={picture} alt="freelance" />

Bravo à vous ! Vous avez généré des éléments span et img auxquels vous avez appliqué du style avec styled-components ! Vous vous en doutez, ça ne s'arrête pas à span et img : vous pouvez ainsi générer tous les éléments du DOM... mais pas que.

Style components prévoit ce cas ! Prenons l'exemple de Header/index.jsx. Pour cela, il nous suffit de faire :

import { Link } from 'react-router-dom'
import styled from 'styled-components'

const StyledLink = styled(Link)`
`

function Header() {
    return (
        <nav>
            <StyledLink to="/">Accueil</StyledLink>
            <StyledLink to="/survey/1">Questionnaire</StyledLink>
            <StyledLink to="/freelances">Profils</StyledLink>
        </nav>
    )
}

export default Header

Essayez ce code pour voir le rendu vous-même - pas mal, n'est-ce pas ?

Information

D'ailleurs, vous pouvez styliser un composant que vous avez vous-même créé de la même manière. Cette technique permet d'étendre votre style.

Passez des props dans votre CSS :

C'est bien beau, nous avons créé nos styled components et nous les avons utilisés, mais quels autres avantages tire-t-on du fait d'écrire notre style avec du JS ? ...Eh bien justement, on utilise du JS. On va pouvoir passer des props à nos composants directement depuis notre composant React.

Voyons voir concrètement ce que cela donne dans notre Header.

<styledLink to="/survey/1" $isFullLink>
    Faire le test
</StyledLink>

Ici on passe la prop 1isFullLink. Ce qui nous permet d'utiliser la prop directement dans le style :

const StyledLink = styled(Link)`
    padding: 15px;
    color: #8186a0;
    text-decoration: none;
    font-size: 18px;
    ${(props) =>
        props.$isFullLink &&
        `color; wite; border-radius: 300px; background-color; #5843E4;`}
`

C'est quoi ce $ ? Eh bien, cela permet de signaler à styled-components que notre prop nous sert pour le style, et qu'elle ne doit pas être passée dans le DOM.

Ce $ est uniquement nécessaire pour passer une prop si le composant en question est un composant React, comme ici pour Link (et non un élément HTML). Si mon styled component était basé sur une simple balise a, je pourrais totalement utiliser la prop isFullLink sans le $.

Voyons l'utilisation du state en prop de plus près ci-dessous :

// Dans /pages/Home/index.jsx
import { useState } from 'react'
import styled from 'styled-components'

const HomeContainer = styled.div`
    height: 100px;
    width: 100px;
    border-radius: 50px;
    background-color: #e20202;
    transform: scale(${({ size }) => size});
`

function Home() {
    const [size, setSize] = useState(1)
    return (
        <HomeContainer>
            <h1 onClick={() => setSize(size + 0.1)}>Page d'accueil</h1>
            <Ballon size={size} />
        </HomeContainer>
    )
}

export default Home

Pas mal, non ?

Utilisez des variables :

Vous voyez, dans notre avant-dernier snippet de code, on a encore utilisé la couleur violette #5843E4. Qui dit JS dit aussi qu'on peut utiliser des variables, et c'est ce que nous allons faire : nous allons utiliser des variables pour stocker nos couleurs !

Vous pourriez tout simplement déclarer un objet colors qui reprend toutes les couleurs de notre application, mais il est considéré comme une bonne pratique de créer un thème géré par styled-components.

On crée donc un dossier /utils directement dans src/, dans lequel on met un dossier /style. On y crée notre fichier color.js, ce qui nous donne :

├── assets
│   └── profile.png
├── components
│   ├── Card
│   │   └── index.jsx
│   ├── Error
│   │   └── index.jsx
│   └── Header
│       └── index.jsx
├── index.jsx
├── pages
│   ├── Freelances
│   │   └── index.jsx
│   ├── Home
│   │   └── index.jsx
│   ├── Results
│   │   └── index.jsx
│   └── Survey
│       └── index.jsx
└── utils
└── style
└── colors.js

Dans color.js, on définit nos couleurs :

const colors = {
    primary '#5843E4',
    secondary: '8186A0',
    backgroundLight: '#F9F9FC',
}

export default colors

Pour l'utiliser, il nous suffit de l'importer directement dans notre template string :

const StyledLink = styled(Link)`
    padding: 15px;
    color: #8186a0;
    text-decoration: none;
    font-size: 18px;
    ${(props) =>
        props.$isFullLink && 
        `color: white; border-radius: 30px; background-color: ${colors.primary};`}
`

Et on a bien ce qu'on voulait.

Par contre, comment faire si je veux styliser mon composant au survol de la souris ? Eh bien, c'est très simple ici puisque les pseudosélecteurs fonctionnent dans nos styled components.

Pour mettre tout ça en pratique, on va retourner sur nos Cards. On y ajoutera un peu de style pour que l'effet de hover soit plus visible. Dans pages/freelances.jsx, on met :

const CardContainer = styled.div`
    display: grid;
    gap: 24px;
    grid-template-rows: 350px 350px;
    grid-template-columns: repeat(2, 1fr);
`

... qu'on utilise tout de suite dans le même fichier :

function Freekabces() {
    return (
        <div>
            <h1>Freelances</h1>
            <CardsContainer>
                {freelanceProfiles.map((profile, index) => (
                    <Card
                        key={`${profile.name}-${index}`}
                        label={profile.jobTitle}
                        title={profile.name}
                    />
                ))}
            </CardContainer>
        </div>
    )
}

Puis dans Card/index.jsx, on peut créer un effet d'ombre au survol de la souris. Pour ça, on crée un CardWrapper qui vient remplacer notre précédente div :

function Card({ label, title, picture }) {
    return (
        <CardWrapper>
            <CardLabel>{label}</CardLabel>
            <CardImage src={picture} alt="freelance" />
            <span>{title}</span>
        </CardWrapper>
    )
}

Et on définit CardWrapper comme suit :

const CardWrapper = styled.div`
    display: flex;
    flex-direction: column;
    padding: 15px;
    background-color: ${colors.backgroundLight};
    border-radius: 30px;
    width: 350px;
    transition: 200ms;
    &:hover {
        cursor: pointer;
        box-shadow: 2px 2px 10px #e2e3e9;
    }
`

La syntaxe &:hover nous permet d'accéder au pseudosélecteur du survol de la souris, et on a bien l'effet souhaité !

On a bien l'effet d'ombre au survol de la souris.
Information

Ici, nous avons utilisé le pseudosélecteur :hover avec &. & est très utile dans nos styled components, notamment pour utiliser des pseudosélecteurs, mais aussi pour accéder à d'autres éléments. Notamment si un de nos compoisants a className et qu'on ne peut pas accéder directement à son style. Je ne vous le montrerai pas ici, mais vous trouverez des exemples dans la documentation de Styled Components.

Créez un style global :

Nous avons déjà vu beaucoup de choses avec styled components. Encore une fois, nous ne pouvons pas tout couvrir dans ce cours, mais avant de conclure j'aimerais que nous créions un style global. Cela nous permettra de créer un style de base, notamment pour la police ou pour d'autres propriétés CSS.

Pour cela, dans index.jsx à la racine de notre projet, vous pouvez créer un composant GlobalStyle :

const GlobalStyle = createGlobalStyle`
    div {
        font-family: 'Trebuchet MS', Helvetica, sans-serif;
    }
`

Et vous l'importez tout simplement dans vos composants :

<Router>
    <GlobalStyle />
    <Header />
    ...
</Router>

Et voilà ! Ensemble, nous avons utilisé styled-components pour styliser notre application !

Exploitez vos connaissances de useState et useEffect pour effectuer des calls API :

Rafraîchissez vos connaissances de useState et useEffect :

Souvenez-vous...

Le state local esy présent à l'intérieur d'un composant : ce composant peut être re-render autant de fois que l'on veut, mais les données seront préservées. Pour cela on utilise useState, un hook qui permet d'ajouter un state local dans un composant fonction.

useEffect est également un hook, qui permet d'exécuter des actions après le render de nos composants, en choisissant à quel moment et à quelle fréquence cette action doit être exécutée, avec le tableau de dépendances.

Vous vous en doutez sûrement, nous allons les utiliser pour faire des calls API :

  • useEffect nous permettra de déclencher le fetch;

  • useState permettra de stocker le retour de l'API dans le state.

Récupérez les données d'une API :

Reprenez les bases des calls API :

Les données sont au coeur d'une application. Qu'il s'agisse de données locales ou bien qu'elles soient récupérées depuis une API, elles viennent alimenter nos composants et nourrir les interactions avec les utilisateurs.

Une API (Application Programming Interface) est littéralement une interface de programmation d'application : c'est un moyen de communication entre deux logiciels. Concrètement, pour nous, c'est ce qui nous permet de récupérer des données.

Mais pourquoi on n'a pas mis les données directement dans le front ? Eh bien... oui, on aurait pu. Ici, on dispose de toutes les données. Mais dans les faits, ce ne sera pas toujours le cas, loin de là. Vous pouvez par exemple avoir besoin que votre contenu soit administré par une personne qui ne sait pas coder. Dans ce cas, vous pourriez utiliser un CMS (comme WordPress, Ghost, etc.), et récupérer le contenu.

Ou bien tout silmplement, vous pouvez créer une application complexe qui requiert un système d'authentification, qui sauvegarde des données utilisateurs, etc. Dans ce cas, une application frontend ne suffit pas et doit être complémentaire avec l'application backend.

Mettons tout ça en application dès maintenant !

Pour notre projet pour l'agence Shiny, nous allons utiliser l'API que vous trouverez ici. Vous trouverez toutes les instructions nécessaires pour la faire tourner dans le README.md. Je vous laisse un petit moment pour cloner le repo et lancer l'API en local.

... C'est bon, vous avez bien l'API qui tourne en local ? On va aller récupérer le contenu de nos questions sur l'API sur la route http://localhost:8000/ avec la méthode fetch().

fetch() est la méthode native pour faire des calls API. Nous aurions très bien pu utiliser des outils tels que axios, mais ici la méthode native a été privilégiée, pour vous éviter une nouvelle installation d'outil externe.

Développez le questionnaire avec les données :

Pour nous atteler à l'utilisation de l'API, nous allons développer la page /survey. Souvenez-vous, précédemment, nous avions créé des liens pour naviguer entre les questions, et rediriger l'utilisateur sur /results quand il atteignait la dixième question. Eh bien, ici, nous allons continuer à développer cette page afo, de récupérer les données depuis l'API.

L'API nous renvoie l'ensemble de questions sur l'endpoint http://localhost:8000/survey.

Hé, mais comment on sait ça ? Bon, c'est facile pour moi parce que j'ai aussi écrit l'API que nous utilisons. Mais vous pouvez tout simplement utiliser la documentation de l'API accessible dans le fichier README.md.

On peut donc l'appeler, comme nous l'avons dit, dans notre useEffect pour récupérer les questions. Si vous regardez la documentation, vous verrez que la route correspondant aux questions (http://localhost:8000/survey) est une route GET, et qu'elle ne requiert pas de paramètre : on pourra récupérer les données en faisant fetch('http://localhost:8000/survey').

Ici, on a donc uniquement besoin d'appeler l'API à la première initialisation de notre composant, et on précise un tableau de dépendances vide dans notre fichier :

useEffect(() => {
    fetch('http://localhost:8000/survey')
        .then((response) => response.json())
        .then(({ surveyData }) => console.log(surveyData))
        .catch((error) => console.log(error))
}, [])

Et voilà : on a bien ce qu'on voulait !

Les données de notre questionnaire arrivent dans la console.

Ici, nous avons utilisé des Promises. Une autre syntaxe aurait été possible avec des async / await. Mais attention, il y a une petite subtilité avec useEffect.

Bon, ce n'est pas tout d'afficher le retour de notre API dans la console : on veut que ce soit visible dans notre application !

Pour cela, nous allons utiliser le state. À l'aide de useState, on crée donc :

const [questions, setQuestions] = useState({})

questions va nous permettre de stocker l'objet qui a été retourné par l'API. À partir de là, on peut exploiter questions assez simplement en appelant : setQuestions(surveyData).

Ici, vous avez pu voir dans votre console que surveyData est un objet ayant pour clé des nombres. C'est très pratique pour s'assurer que les questions sont toujours ordonnées, et on peut tout simplement accéder à une question avec :

surveyData[questionNumber]

De la même manière, pour savoir s'il faut mettre un lien vers le numéro de question suivant, ou bien un lien vers les résultats, vous pouvez tout simplement vérifier ce que donne l'affirmation :

surveyData[questionNumberInt + 1] ?

Ce qui nous donne le code suivant :

function Survey() {
    const { questionNumber } = useParams()
    const questionNumberInt = parseInt(questionNumber)
    const prevQuestionNumber = questionNumberInt === 1 ? 1 : questionNumberInt - 1
    const nextQuestionNumber = questionNumberInt + 1
    const [surveyData, setSurveyData] = useState({})

    useEffect(() => {
        setDataLoading(true)
        fetch('http://localhost:8000/survey')
            .then((response) => response.json())
            .then(({ surveyData }) => console.log(surveyData))
            .catch((error) => console.log(error))
    }, [])

    return (
        <SurveyContainer>
            <QuestionTitke>Question {questionNumber}</questionNumber>
            <QuestionContent>{surveyData[questionNumber]}</QuestionContent>
            <LinkWrapper>
                <Link to={`/survey/${prevQuestionNumber}`}>Précédent</Précédent</Link>
                {surveyData[questionNumberInt + 1] ? (
                    <Link to={`/survey/${nextQuestionNumber}`}>Suivant</Link>
                ) : (
                    <Link to="/results">Résultats</Link>
                )}
            </LinkWrapper>
        <SurveyContainer>
    )
}

export default Survey

Créez un state loading :

C'est pas mal tout ça, n'est-ce pas ? Notre question s'affiche bien :

Votre application affiche bien la question souhaitée.

Mais ça vient d'où ce petit moment de "blanc" ? Comment fait-on pour que ça ressemble plus aux sites professionnels ? Eh bien, ça correspond tout simple au temps entre lequel lequel le composant est render (généré) et celui où leuel les données sont chargées. Effectivement, d'un point de vue UI (interface utilisateur), ce n'est pas idéal : l'utilisateur ne comprend pas que les données sont en train d'être chargées, et peut alors penser qu'uk y a un problème sur l'application.

Une pratique très répandue consiste à mettre un petit loader pour signifier que les données vont bientôt s'afficher. On pourrait mettre un simple texte "Chargement...", mais bon, on sait manier le CSS : autant s'amuser avec, non ?

Je vous propose de créer un simple Loader en CSS, directement dans le fichier utils.Atoms.jsx. Pour cela, on a également besoin d'importer keyframes depuis la bibliothèque styled-components. Ce qui nous donne :

import colors from './colors'
import styled, { keyframes } from 'styled-components'

const rotate = keyframes`
    from {
        transform: rotate(0deg);
    }

    to {
        transform: rotate(360deg);
    }
`

export const Loader = styled.div`
    padding: 10px;
    border: 6px solid ${colors.primary};
    border-bottom-color: transparent;
    border-radius: 22px;
    animation: ${rotate} 1s infinite linear;
    height: 0;
    width: 0;
`
Information

Ici, ce CSS n'est pas l'objet de ce cours, donc pas de panique si vous ne comprenez pas tout : sachez juste qu'il s'agit d'une utilisation un peu détournée pour avoir un Loader en CSS pur.

On va maintenant utiliser le state pour afficher notre Loader. Pour cela, on crée une variable isDataLoading avec useState :

const [isDataLoading, setDataLoading] = useState(false)

Dans le useEffect, on vient modifier notre booléen :

useEffect(() => {
    setDataLoading(true)
    fetch(`http://localhost:8000/survey`)
        .then((response) => response.json())
        .then(({ surveyData }) => {
            setSurveyData(surveyData)
            setDataLoading(false)
        })
}, [])

... ce ui nous permet ainsi de condititionner le rendu de notre composant : le Loader s'affiche tant que les données chargent, et une fois qu'on les a bien, le contenu de la question s'affiche à la place du Loader.

<SurveyContainer>
    <QuestionTitle>Question {questionNumber}</QuestionTitle>
    {isDataLoading ? (
        <Loader />
    ) : (
        <QuestionContent>{surveyData[questionNumber]}</QuestionContent>
    )}
    ...
</SurveyContainer>

On a bien notre contenu qui s'affiche comme on le souhaitait !

Pas mal, ce petit loader ?

Maintenant que nous avons le comportement que nous souhaitons, profitons-en pour implémenter une syntaxe un peu plus moderne, et pour gérer les erreurs :

useEffect(() => {
    async function fetchSurvey() {
        try {
            const response = await fetch(`http://localhost:8000/survey`)
            const { surveyData } = await response.json()
            setSurveyData(surveyData)
        } catch(err) {
            console.log(err)
            setError(true)
        } finally {
            setDataLoading(false)
        }
    }
    fetchSurvey()
}, [])

Partagez vos données avec le Contexte et useContext :

Découvrez le Contexte dans React :

Dans la famille des hooks, je veudx maintenant celui qui nous permet d'utiliser simplement le Contexte de React !

Contexte est un moyen de partage simplement les props entre les composants. Contexte est natif à React et ne nécessite pas d'installer quoi que ce soit de plus.

Précédemment, vous avez vu comment passer de simples props entre les composants parents et enfants, et comment utiliser les props pour faire remonter le state. Mais est-ce que vous imaginez ce que ça pourrait donner dans une application complexe, où pour passer une prop à un composant enfant, vous devez le faire par des dizaines de composants parents qui n'ont eux-mêmes pas beosin de cette prop ?

Ça n'a pas l'air très efficace, n'est-ce pas ?

À l'inverse, Contexte nous permet de récupérer simplement nos datas sans avoir à tout passer manuellement. Pour cela, on englobe le composant le plus haut dans l'arborescence de composants avec ce qu'on appelle un Provider. Tous les composants enfants pourront alors se connecter au Provider (littéralement en anglais, le "fournisseur") et aisi accéder aux props, sans avoir à passer par tous les composants intermédiaires. On dit que les composants enfants sont les Consumers (consommateurs).

Cette fois-ci, le composant se branche tout simplement au Provider pour accéder au Contexte !

L'idée de passer simplement nos datas entre les composants est au coeur de nombreuses questions, afin que le code soit le plus performant et lisible possible. Vous verre plus particulièrement des interrogations sur la manière de gérer le state de manière compréhensible et performante. On parle de State Management. Vous en avez déjà entendu parler ?

Information

Comme son nom l'indique, le State Management cherche à optimiser la gestion du State. Il existe des solutions dédiées telles que Redux (Redux nécessite d'installer une bibliothèque externe). Le Contexte permet de manipuler des variables liées au state. Mais il ne s'agit pas vraiment à proprement parler d'une solution de State Management. Contexte ne vient donc pas remplacer Redux, mais cohabite avec Redux dans la plupart des codebases. Comprendre le State Management est essentiem si vous souhaitez travailler sur une base de code en entreprise.

Avant les hooks, l'utilisation du Contexte était plus laborieuse, mais réjouissez-vous : vous avez maintenant le hook useContext.

Tirez profit du COntexte et de useContext :

Maintenant que nous avons vu ce qu'était un OCntexte, utilisons-le dès maintenant dans notre application Shiny !

Mettez en place un Contexte :

Quelles données mettre dans le Contexte ? La documentation de React dit que "le Contexte est conçu pour partager des données qui peuvent être considérées comme globales", et cite en exemple des données sur l'utilisateur actuellement authentifié, le thème, la langue utilisée, etc.

Nous allons commencer en douceur avec le contexte pour créer un Dark Mode (mode nuit) pour notre application. Vous savez, ce thème de couleurs plutôt sombres qui permet de reposer devant l'écran.

Pour cela, nous allons créer un Footer dans lequel on ajoute un bouton. Dans /components, on crée donc un fichier Footer/index.jsx au fonctionnement assez basique :

import styled from 'styled-components'
import colors from '../../utils/style/colors'

function FooterContainer = styled.footer`
    display: flex;
    flex-direction: row;
    align-items: center;
    justify-content: center;
    padding-top: 60px;
`

const NightModeButton = styled.button`
    background-color: transparent;
    border: none;
    cursor: pointer;
    color: ${colors.secondary};
`

function Footer() {
    return (
        <FooterContainer>
            <NightModeButton>Changer de mode</NightModeButton>
        </FooterContainer>
    )
}

export default Footer

Ce bouton dans notre Footer permettra de déclencher le dark mode de l'application. C'est maintenant le moment de créer notre Provider de Contexte pour le thème. Pour cela, on va créer un dossier dédié au Contexte dans utils/context. On crée un fichier index.jsx.

On commence par importer { createContext } depuis react, et initialiser notre Contexte pour le thème avec :

export const ThemeContexte = createContext()

Et on utilise ensuite ThemeContext :

export const ThemeProvider = ({ children }) => {
    const [theme, setTheme] = useState('light')
    const toggleTheme = () => {
        setTheme(theme === 'light' ? 'dark' : 'light')
    }

    return (
        <ThemeContext.Provider value={{ theme, toggleTheme }}>
            {children}
        </ThemeContext.Provider>
    )
}

On a bien créé un composant qui nous permet de wrapper notre composant parent avec Provider de thème. Le state de theme et sa fonction pour le modifier, setTheme, sont passés dans les values. Ainsi, tous les composants enfants qui se retrouvent englobés par le Provvider vont pouvoir accéder à theme et setTheme.

C'est le moment d'utiliser notre Provider au plus haut niveau où les composants devront pouvoir accéder au Contexte. On va donc le mettre dans index.jsx à la racine de /src. On a donc maintenant :

ReactDOM.render(
    <React.StrictMode>
        <Router>
            <ThemeProvider>
                <GlobalStyle />
                <Header />
                <Routes>
                    <Route path="/" element={<Home />} />
                    <Route path="/survey+:questionNumber" element={<Survey />} />
                    <Route path="/results" element={<Results />} />
                    <Route path="/freelances" element={<Freelances />} />
                    <Route path="*" element={<Error />} />
                </Routes>
            </ThemeProvider>
        </Router>
    </React.StrictMode>,
    document.getElementById('root')
);

Ça veut dire qu'on doit forcément mettre notre Provider au niveau de notre router ? Eh bien... Pas nécessairement ! Comme son nom l'indique, le Contexte nous sert à "contextualiser" nos datas. Cetaines parties de l'application ont besoin d'être au courant d'une partie du state, alors qu'il n'y en a pas du tout besoin ailleurs. À vous de voir quelle utilisation est la plus adaptée.

Accédez à vos données avec useContext :

useContext est un hook qui permet de se "brancher" depuis un composant enfant qui a été wrappé par un Provider, et donc d'accéder simplement au state partagé.

Mettons-le en pratique dès maintenant.

On va d'abord utiliser le theme pour effectuer une modification très visible : modifier le background-color de toute notre application.

Auparavant, on avait notre GlobalStyle dans notre fichier index.jsx à la racine de /src, mais on va le déplacer dans un fichier à part dans /utils/style/GlobalStyle.jsx. On va également modier GlobalStyle en composant fonction qui va nous permettre d'y utiliser des hooks (alors qu'avant c'était un simple style component). On a donc :

function GlobalStyle() {
    return <StyledGlobalStyle />
}

Puis on importe ThemeContext et useContext avec :

import { useContext } from 'react'
import { ThemeContext } from '../context/ThemeProvider'

Ce qui nous permet de récupérer le thème :

function GlobalStyle() {
    const { theme } = useContext(ThemeContext)

    return <StyledGlobalStyle isDarkMode={theme === 'dark'} />
}

... et donc de passer une prop isDarkMode en fonction du thème activé.

Dans notre Style, on l'utilise ainsi :

const StyledGlobalStyle = createGlobalStyle`
    * {
        font-family: 'Trebuchet MS', Helvetica, sans-serif;
    }

    body {
        /* Ici cette syntaxe revient au même que
        background-color: ${({ props}) =>
        props.isDarkMode ? '#2F241' : 'white'};
        */
        background-color: ${({ isDarkMode }) => (isDarkMode ? 'black' : 'white')};
        margin: 0;
    }
`

Et c'est parti pour la dernière pièce de notre puzzle : l'implémentation du bouton permettant de changer de mode.

On retourne donc dans notre Footer/index.jsx. De la même manière que ce qu'on a fait juste avant, on importe ThemeContext et useContext.

Et on récupère notre action toggleTheme et theme avec :

const { toggleTheme, theme } = useContext(ThemeContext)

... qu'on peut utiliser juste en dessous :

function Footer() {
    const { toggleTheme, theme } = useContext(ThemeContext)
    return (
        <FooterContainer>
            <NightModeButton onClick={() => toggleTheme()}>
                Changer de mode : {theme === 'light' ? '☀️' : '🌙'}
            </NightModeButton>
        </FooterContainer>
    )
}

Et si on teste...

La couleur du backgrouynd change bien au clic sur le bouton.

Bravo à vous : vous venez de créer un Contexte et vous l'avez utilisé avec useContext pour créer un dark mode dans votre application.

Comme je vous le disais, vous êtes un chanceux : avant les hooks, le Contexte était plus compliqué à mettre en oeuvre. Je vous fais une démonstration juste en dessous de comment ça se passait.

// import { useContext } from 'react'
import { ThemeContext } from '../../utils/context'
import styled from 'styled-components'
import colors from '../../utils/style/colors'

const FooterContainer = styled.footer`
    display: flex;
    flex-direction: row;
    align-item: center;
    justify-content: center;
    padding-top: 60px;
`

const NightModeButton = styled.button`
    background-color: transparent;
    border: none;
    cursor: pointer;
    color: ${colors.secondary};
`

function Footer() {
    // const { toggleTheme, theme } = useContext(ThemeContext)

    return (
        <ThemeContext.Consumer>
            {({toggleTheme, theme}) => (
                <FooterContainer>
                    <NightModeButton onClick={() => toggleTheme()}>
                        Changer de mode : {theme === 'light' ? '☀️' : '🌙'}
                    </NightModeButton>
                </FooterContainer>
            )}
        </ThemeContext.Consumer>
    )
}

export defaut Footer

Allez plus loin avec les hooks :

Précédemment, vous avez découvert les hooks avec useState et useEffect et, ensemble, nous avons appris à les utiliser pour faire des calls API. Vous avez également découvert le Contexte et comment y accéder simplement avec useContext : vous commencez à avoir quelques cartes en main pour utiliser les hooks dans vos applications React.

Et si je vous disais que vous pouvez créer vos propres hooks ?

Créez vos propres hooks pour simplifier votre code :

Je ne sais pas ce que ça évoque pour vous, mais moi la prmeière fois qu'on m'a dit que je pouvais créer mes propres hooks, ça m'a un peu fait paniquer. Je m'imaginais devoir travailler sur la codebase de React, et devoir créer un de ces fichiers immenses pour pouvoir faire tourner un hook "custom".

Détrompez-vous, la création d'un hook "custom" est toute simple : il s'agit juste d'une fonction qui commence par "use", qui extrait de la logique réutilisable et qui peut utiliser d'autres hooks.

Information

Comme pour tout le reste, créer des hooks n'est pas obligatoire, mais cela peut vous simplifier la vie ! Cette approche vous aidera à mettre en application le principe "DRY" ("don't reapeat yourself" ou "ne vous répétez pas"), avec des bouts de logique qui deviennent réutilisables à travers votre application.

Mettons en application ce que nous avons vu dès maintenant.

Créez un hook pour vos calls API :

Précédemment, vous avez appelé l'API du projet Shiny pour récupérer les profils des freelances, ainsi que les questions de notre questionnaire.

Si vous regardez votre code d'un peu plus près, vous aussi vous voyez une certaine répétition ? C'est normal. On va donc essayer de mutualiser tout ça avec un hook "personnalisé" !

Pour cela, on crée dans /utils un nouveau dossier qu'on appelle /hooks et dans lequel on crée un fichier index.jsx.

On va créer notre hook qu'on base cette fois-ci sur la syntaxe async / await :

import { useState, useEffect } from 'react'

export function useFetch(url) {
    const [data, setData] = useState({})
    const [isLoading, setLoading] = useState(true)

    useEffect(() => {
        if (!url) return

        async function fetchData() {
            const response = await fetch(url)
            const data = await response.json()
            setData(data)
            setLoading(false)
        }
        
        setLoading(true)
        fetchData()
    }, [url])

    return { isLoading, data }
}

Le code est plutôt explicite, n'est-ce pas ? Pour notre nouveau hook, useFetch, on lui passe en paramlètre l'URL de l'API qu'on veut appeler. Il possède un state interne qui lui permet de stocker la data, et de savoir si la data est en train de charger avec isLoading.

Dans useEffect, le hook fait un return vide si le paramètre de l'URL est vide, et commence par mettre isLoading à true. Il déclare la fonction asynchrone fetchData qui permet de :

  • appeler fetch;

  • parcer ce qui est retourné avec data.json();

  • et changer l'état de isLoading.

url fait partie du tableau de dépendances du useEffect, ce qui permettra de redéclencher le call en cas de changement d'URL passée en paramètre. Puis on appelle notre fonction fetchData.

Pour utiliser notre nouveau hook, modifions /Survey/index.jsx. On commence par importer notre hook avec :

import { useFetch } from '../utils/hooks'

Puis on récupère notre data avec :

const { data, isLoading } = useFetch(`http://localhost:8000/survey`)

const { surveyData } = data

Je vous invite aussi à supprimer tout le contenu du useEffect qui permettait d'effectuer le fetch. J'adore supprimer du code inutile : c'est super satisfaisant, non ?

On n'oublie pas de remplacer isDataLoading par isLoading qui sera plus générique ici, et de remplacer surveyData par data.

Est-ce que ça marche toujours ? ... Oh, on a une erreur qui empêche le code de tourner !

Pas de panique, c'est normal ! Pour faire la navigation, on a accédé au contenu de notre objet de questions en faisant data[questionNumber]. Sauf qu'à l'initialisation, data est un objet vide... Ce qui veut dire qu'avec :

const { surveyData } = data

surveyData est undefined. JavaScript provoque donc une erreur pour data[questionNumber]. Une des manières déviter l'erreur est donc de vérifier que surveyData est défini avant de l'utiliser dans le composant :

<QuestionContent>

    {surveyData && surveyData[questionNumber]}

</QuestionContent>

Et voilà, notre page fonctionne à nouveau comme avant, en utilisant notre hook useFetch, ce qui a permis de supprimer pas mal de code répétitif !

Ajoutez une grestion d'erreur :

Mais que se passe-t-il quand l'API nous renvoie une erreur ? Notre application ne se comportera pas comme prévu, et l'utilisateur n'en sera même pas informé. Je vous propose d'intégrer une gestion d'erreur dans notre hook useFetch afin d'afficher à l'écran qu'il y a eu un problème.

Pour cela, dans utils/hooks/index.jsx, on peut créer un state pour error avec :

const [error, setError] = useState(false)

Puis, on ajoute un try et un catch à notre cher hook useFetch :

const function useFetch(url) {
    const [data, setData] = useState({})
    const [isLoading, setLoading] = useState(true)
    const [error, setError] = useState(false)

    useEffect(() => {
        if (!url) return
        setLoading(true)

        async function fetchData() {
            try {
                const response = await fetch(url)
                const data = await response.json()
                setData(data)
            } catch (err) {
                console.log(err)
                setError(true)
            } finally {
                setLoading(false)
            }
        }

        fetchData()
    }, [url])

    return { isLoading, data, error }
}

Ce qui nous permet de passer error à true lorsqu'un problème est rencontré.

De la même manière, dans Survey.jsx, on récupère maintenant error :

const { data, isLoading, error } = useFetch(`http://localhost:8000/survey`)

Et on peut tout simplement ajouter au-dessous du return :

if (error) {
    return <span>Il y a un problème</span>
}
Information

Si vous voulez tester votre nouvelle gestion d'erreur, vous pouvez tout simplement stopper l'API et voir ce qui se passe... !

Et voilà, vous avez même géré les erreurs d'appel API !

Nous allons en profiter pour utiliser useFetch pour envoyer les réponses de l'utilisateur, et donc récupérer les résultats de notre API depuis la page /results.

function formatQueryParams(answers) {
    const answersNumbers = Object.keys(answers)

    return answersNumbers.reduce((previousParams, answerNumber, index) => {
        const isFirstAnswer = index === 0
        const separator = isFirstAnswer ? '' : '&'
        return `${previousParams}${separator}a${answerNumber}=${answers[answerNumber]}`
    }, '')
}

function Results() {
    const { theme } = useContext(ThemeContext)
    const { answers } = useContext(SurveyContext)
    const queryParams = formatQueryParams(answers)

    const { data, isLoading, error } = useFetch(`http://localhost:8000/results?${queryParams}`)

    console.log('===== data =====', data)

    if (error) {
        return <span>Il y a un problème</span>
    }

    const resultData = data?.resultsData

    return isLoading ? (
        <LoaderWrapper>
            <Loader />
        </LoaderWrapper>
    ) : (
        <ResultsContainer theme={theme}>
            <ResultTitle thme={theme}>
            ...
        </ResultsContainer>
    )
}

C'est pas mal ce useFetch, je vais pouvoir l'implémenter dans toutes les bases de code en production ? Le hook useFetch que nous avons codé ici est très pratique pour éviter de nous répéter... et pour pratiquer la création de hooks personnalisés. Mais dans les faits, ce code est un peu trop basique pour être utilisé en production. À la place, vous pouvez utiliser un outil bien plus robuste tel que TanStack Query pour React, qui vous permet d'utiliser des hooks pour faire vos requêtes et les mettre en cache dans vos applications.

Maîtrisez les hooks :

Intégrez les règles des hooks :

Je le rappelle ici : les hooks ont leurs propres règles d'utilisation.

  • Les hooks sont uniquement accessibles dans un composant fonction React. Donc ce n'est pas possible d'en utiliser dans un composant classe ou bien dans une simple fonction JavaScript.

  • Appelez les hooks au niveau racine de vos composants.

  • Attention au nommage de vos hooks personnalisés : même s'il ne s'agit pas vraiment d'une règle obligatoire, mais d'une convention, vos hooks personnalisés doivent commencer par use pour que l'on sache en un coup d'oeil qu'il s'agit d'un hook.

Information

Effectivement, il existe d'autres hooks mis à disposition par React, dont vous pouvez trouver la liste sur la documentation.

Pour l'instant, voici quelques hooks que vous pourriez être susceptible de croiser dans différentes codebases :

useRef
je vous laisse regarder par vous-même la documentation si ça vous intéresse, mais même s'il existe plusieurs utilisations de useRef, ce hook est avant tout utilisé pour interagir avec des éléments du DOM.
useReducer
useReducer permet de mieux gérer votre state lorsqu'il comporte de nombreuses propriétés qui doivent être modifiées régulièrement.
useMemo et useCallback
Ces deux hooks nous permettent d'éviter de refaire des calculs coûteux pour nos performances. Vous pouvez préciser des valeurs pour lesquelles il faudra refaire les calculs uniquement si l'un des paramètres change, grâce à useMemo et useCallback.

Et il en existe encore d'autres... À l'heure où ce cours a été écrit, nous en sommes à la version 17 de React. Mais nous ne sommes pas à l'abri que d'autres hooks soient créés entretemps.

Découvrez la base des tests dans React avec Jest :

Utilisez les tests automatisés dans React :

Peu importe le langage dans lequel vous développez, les tests font partie intégrante du métier de développeur, même en frontend. Ils permettent de s'assurer de la fiabilité de votre code.

Comprenez l'utilité des tests :

Rédiger des temps prend du temps et de la réflexion. Et pourtant, vous pouvez vous considérer comme chanceux : depuis quelques années apparaissent des outils de plus en plus simples à utiliser pour tester le JS. Encore une fois, quand vous codez seul sur une petite application, ça peut vite vous sembler pénible pour pas grand-chose. Mais essayez de vous projeter.

Lorsque vous travaillez sur une base de code qui comporte de nombreuses fonctionnalités, et que vous codez à plusieurs, il est si simple de faire une modification qui amène une régression (introduction d'un bug en production). Surtout lorsque vous touchez à du code que vous n'avez pas écrit vous-même... et donc pour lequel vous ne saisissez pas toujours toutes les subtilités. Dans ces cas-là, c'est un vrai atout de savoir que vous pouvez compter sur les tests pour vous signaler une erreur ! Vous évitez les régressions, et vous gagnez en confiance sur vos modifications.

Information

Un des meilleurs moyens pour s'obliger à recouvrir aux tests en équipe est d'intégrer une étape "test" directement lorsque vous pushez votre travail sur la plateforme où vous hévergez votre code versionné, typiquement sur GitHub. On appelle ça l'intégration continue. Chaque commit sur une branche déclenche votre série de tests automatisés, et l'équipe ne merge pas le code si les tests ne fonctionnnent pas, ou bien si le code coverage n'est pas suffisant (nous verrons ce que c'est dans un très court instant).

Mais "test" est un tout petit mot qui recouvre une réalité bien plus grande : il existe de nombreux types de tests.

Faites la différence entre les types de tests :

Il existe différents types de tests, les principaux sont les tests unitaires, d'intégration et end-to-end.

Information

Une petite explication s'impose :

  • Les tests unitaires vont venir tester une petite partie de votre code de manière totalement indépendante : une fonction, un bout de script... Ils sont les plus rapides à écrire, mais n'assurent pas forcément vos arrières.

  • Les tests end-to-end, quant à eux, permettent de tester l'intégralité d'une fonctionnalité de bout en bout. Ils sont beaucoup plus sécurisants, mais prennent donc beaucoup de temps à écrire.

  • Viennent enfin les tests d'intégration qui sont souvent considérés comme le juste milieu entre sécurité fournie et temps requis pour les rédiger. Ils permettent de tester une fonctionnalité, en simulant des interactions utilisateur pour s'assurer que tout fonctionne bien comme prévu.

Cela ne veut pas dire que vous devez totalement abandonner les tests unitaires et les tests end-to-end, mais qu'il est important de trouver un juste milieu entre les trois types. Par exemple, vous pouvez totalement choisir d'implémenter des tests end-to-end spécifiquement pour une fonctionnalité "critique" de votre application.

Créez votre premier test avec Jest :

Comme tout ce qui a trait à JavaScript, l'écosystème des tests évolue très vite. Dans cette partie, nous allons utiliser Jest et React Testing Library.

Pour sa part, Jest fait partie des outils acclamés depuis plusieurs années, et il se trouve également que Jest est déjà installé Create-React-App. Pas mal pour se lancer dans les tests.

Quant à React Testing Library, il s'agit d'une bibliothèque qui donne accès à davantage d'outils permettant de tester des composants. Nous la découvrirons un peu plus tard.

Si vous vous demandez comment les deux s'articulent, vous pouvez vous dire que Jest est l'outil de base pour vos tests, et que React Testing Library est l'outil qui vous facilite les tests de composants.

Plongeons ensemble dans le monde des tests avec un premier exemple de Jest :

Par exemple, dans Home/index.jsx, on a une fonction sum qui additionne deux nombres :

export function sum(a, b) {
    return a + b
}

On va créer le fichier de tests Home/index.test.js :

import {sum} from './'

test('Ma fonction sum', () => {
    const result = sum(3, 7)
    expect(result).toBe(10)
})

Et on lance la commande suivante :

yarn run test

Maintenant, écrivons un test unitaire.

Préparez votre code :

Pour tester notre code de manière indépendante, nous allons sortir une partie de notre logique sur la page /Results/index.jsx. On peut faire la fonction :

export function formatJobList(title, listLength, index) {
    if (index === listLength - 1) {
        return title
    }
    return `${title},`
}

Et dans notre JSX, on met :

<ResultsTitle theme={theme}>
    Les compétences dont vous avez besoin :
    {resultsData &&
        resultsData.amp((result, index) => (
            <JobTitle
                key={`result-title-${index}-${result.title}`}
                theme={theme}
            >
                {formatJobList(result.title, resultsData.length, index)}
            </JobTitle>
        ))}
</ResultsTitle>

Nous voilà dond fin prêts pour notre test.

Créez votre fichier de test :

Depuis quelques temps, tous nos fichiers sont répartis dans des dossiers ayant un nom spécifique et un fichier index.jsx. Eh bien, cette répartition va nous être bien utile car elle va nous permettre de mettre nos tests directement à la racine de chaque dossier.

On va donc commencer par /Results et y créer un fichier index.test.js, et voilà !

Mais comment Jest va retrouver notre fichier de test ? Eh bien, pas de panique. Jest est ici configuré pour chercher dans tous les sous-dossiers (à part node_modules et .git, notamment) à la recherche de fichiers se terminant par spec.js ou test.js, précédé d'un trait d'union (-) ou d'un point (.). C'est également possible de mettre vos tests dans un dossier __tests__.

Comprenez la rédaction du test :

Attelons-nous maintenant à la rédaction du test.

Il nous faut dans un premier temps importer l'élément à tester, puis utiliser test.

On utilise test, mais on ne l'a importé nulle part ? Pourquoi on n'a pas une erreur ici ? Eh bien, test un outil auquel on peut accéder globalement dans un fichier de test grâce à Jest. Il existe d'autres outils globaux, vous pourrez en apprendre davantage sur la documentation (en anglais).

Vérifions dès maintenant que notre test fonctionne. Dans Results/results.test.js, on importe notre fonction, et on prépare le test :

import { formatJobList } from './'

test('Ceci est mon premier test', () => {})

Notez bien que test() prend une string en premier argument, puis une fonction en deuxième argument.

J'essaie dès maintenant de lancer la commande yarn run test dans mon terminal.

Aucun fichier de test n'est trouvé

... C'est complètement normal. Ici, nous n'avons pas écrit le coeur de notre test : exécuter notre fonction, et comparer avec une référence.

Information

Notez ici que Jest ne rend pas la main : il est en mode "watch", c'est-à-dire qu'il surveille vos fichiers et relance les tests appropriés si besoin. Ce mode a été exécuté automatiquement par "react-scripts test". Pour arrêter Jest dans ce cas, il vous suffit d'appuyer sur q (quitter) ou de taper Ctrl+C.

Pour cela, on va utiliser expect et toEqual. Ici, toEqual est ce qui s'appelle un matcher, mis à disposition par Jest. On utilise la fonction expect(), qui va comparer un élément avec notre matcher. Cela nous oblige à nous interroger sur ce qu'on veut obtenir de formatJobList.

On prend par exemple un élément item2 qui sera en deuxième position dans notre liste (son index est donc de 1), mais qui ne sera pas le dernier élément : on veut donc que le titre ajoute une virgule.

Ce qui nous donne :

import { formatJobList } frrom './'

test('Ceci est mon premier test', () => {
    const expectedState = 'item2,'
    expect(formatJobList('item2', 3, 1)).toEqual(expectedState)
})

On sauvegarde, et nos tests se lancent automatiquement (sauf si on a quitté le mode watch). On a bien du vert !

Notre premier test est tout bon !

Vous avez vu : ce n'était pas si dur, n'est-ce pas ?

Information

Vous aurez besoin de nombreux autres matchers pour comparer ce que votre code retourne avec les outils de Jest, par exemple toBe, toContains, etc.

Pour en savoir plus, je vous conseille de lire la documentation Jest (en anglais).

Il existe aussi d'autres fonctions, telles que describe().

describe vous permet d'englober plusieurs tests qui ont un lien entre eux (vous êtes libre de choisir quel est ce lien), et que cela s'affiche de manière plus lisible lorsque vous lancez vos tests. Dans notre exemple, on peut maintenant ajouter un test pour vérifier que notre fonction ne met pas de virgule sur le dernier élément :

import { formatJobList } from './'

describe('La fonction formatJobList', () => {
    test('ajoute une virgule à un item', () => {
        const expectedState = 'item2,'
        expect(formatJobList('item2', 3, 1)).toEqual(epectedState)
    })
    test('ne met pas de virgule pour le dernier élément', () => {
        const expectedState = 'item3'
        expect(formatJobList('item3', 3, 2)).toEqual(expectedState)
    })
})

Ce qui nous donne :

Nos deux tests marchent !

C'est beaucoup plus lisible, n'est-ce pas ?

Information

Jusqu'à maintenant, vous utilisé "test" pour écrire vos tests. Mais il existe un alias pour cette fonction : it().

Comme pour tout, il existe des conventions de rédaction de tests pour que les appellations soient les plus explicites possibles. Une des conventions possibles consiste à commencer tous les tests par "should". Dans ce cas, c'est encore plus explicite d'utiliser l'alias it dont je viens de vous parler. Ce qui aurait donné dans notre cas :

import { formatJobList } from './'

describe('La fonction formatJobList', () => {
    it('ajoute une virgule à un item', () => {
        const expectedState = 'item2,'
        expect(formatJobList('item2', 3, 1)).toEqual(epectedState)
    })
    it('ne met pas de virgule pour le dernier élément', () => {
        const expectedState = 'item3'
        expect(formatJobList('item3', 3, 2)).toEqual(expectedState)
    })
})

Assurez-vous d'avoir le test coverage idéal :

Lorsqu'on commence à avoir des tests, il devient possible de mesurer la couverture de tests (code coverage), c'est-à-dire le pourcentage de notre code - à l'expression près ! - qui est couvert par les tests. On peut alors repérer les parties non testées, ou insuffisamment testées, et savoir ainsi où concentrer nos prochains efforts d'écriture de test.

Lançons dès maintenant la commande nous permettant de vérifier notre code coverage.

Pour cela, je fais yarn test -- --coverage.

Le test coverage s'affiche sous la forme d'un tableau dans le terminal.

On a donc le détail de la couverture de nos tests, y compris des lignes qui ne sont pas couvertes par les tests.

Il existe des services qui permettent d'utiliser la couverture de tests comme critère de blocage pour l'intégration de nouveau code à nos projets, en définissant des exigences de taux absolu plancher, ou l'interdiction de faire baisser le taux existant, pour autoriser une pull request à être fusionnée dans sa branche destinataire.

Il peut être très satisfaisant d'augmenter son code coverage au maximum. Mais attention, le code coverage peut être traître : non seulement, vous pouvez perdre trop de temps afin d'essayer d'obtenir 100% de couverture, ce qui, la plupart du temps, n'est pas nécessaire. D'autant plus quand on sait que les tests doivent être maintenus dans le temps, en gardant la même logique à l'esprit. Et un autre point de vigilance : le coverage ne prend pas du tout compte la pertinence de vos tests. Alors, ne vous laissez pas aveuglément séduire !

Testez vos composants avec React Testing Library :

Pour l'instant, nous avons appris à faire de simples tests unitaires pour tester des fonctions simples. Mais qu'en est-il du comportement de nos composants . Comment vérifier que ce qui est affiché pour l'utilisateur fonctionne bien comme on le souhaite, et qu'il n'y a pas de régession ?

En d'autres mots, comment faire pour tester nos composants ?

Vous avez pu voir précédemment que pour tester, on exécute, et on compare avec ce qui était attendu. Or, nos composants React fournissent des instructions permettant de mettre à jour le DOM. Pour tester nos composants, il faudra donc faire un render, vérifier le DOM généré, et le comparer avec ce qui était attendu.

Information

Vous pouvez en apprendre davantage sur la logique des tests directement sur la documentation de React.

Pour faire ça, nous allons pouvoir utiliser React Testing Library, la bibliothèque dont je vous ai déjà parlé.

Découvrez React Testing Library :

React Testing Library nous donne accès à des outils basés sur react-dom et react-fom/test-utils qui nous permettent de respecter les bonnes pratiques des tests et de profiter de messages d'erreur lisibles.

Cette solution ne remplace pas Jest, au contraire, elle est complémentaire à Jest. React Testing Library nous permet de vraiment nous concentrer sur le DOM, en le recréant, en permettant de simuler des interactions et de vérifier ce qui est rendu? Cela nous aide à nous mettre dans la peau de nos utilisateurs, et à anticiper ce qu'ils verront.

Par exemple, lorsqu'on veut tester un composant qui fait un call API, on n'a pas forcément besoin de vérifier le useEffect et le state qui nous permettent de faire tout ça. La logique de React Testing Library est de vérifier qu'on a bien notre composant, qu'il est remplacé par un loader le temps que les datas chargent, puis qu'il est complété avec les datas qu'on a récupérées.

Mais ici... On a affaire à des tests d'intégration ou des tests unitaires ? Eh bien, React Testing Library nous permet de faire les deux. On va pouvoir tester nos hooks en isolation pour faire des tests unitaires dessus, et tester les interactions entre nos différents composants, faisant ainsi des tests d'intégration.

Lançons-nous dès maintenant dans le test de nos composants avec React Testing Library... !

Créez un test simple d'un composant :

Mais... on n'a même pas installé la bibliothèque ? C'est normal, pas besoin d'installation ici puisque React Testing Library fait maintenant partie des outils istallés de base par Create React App. Il nous suffit juste d'importer ce dont on a besoin dans notre fichier de test.

Nous allons commencer par tester notre composant Footer qui permet de changer de thème. Dans /components/Footer, on crée donc un fichier index.test.js.

Nous allons commencer tout d'abord nous assurer que Footer render bien, sans crasher. Pour cela, on importe Footer, le render de React Testing Library, et on utilise render :

import Footer from './'
import { render } from '@testing-library/react'

describe('Footer', () => {
    test('Should render without crash', async () => {
        render(<Footer />;)
    })
})

Si vous n'avez pas quitté le mode watch, vos tests se lancent automatiquement... Ou sinon, vous pouvez refaire yarn run test. Et on a une erreur...

TypeError: Cannot destructure property toggleTheme of '(0 , _react.useContext)(...)' as it is undefined.

Évidemment ! Notre composant fait partie d'un ensemble qui utilise le Contexte... Or, ici, notre composant n'est pas englobé par notre Provider de thème light / dark.

Pas de panique, vous vous en doutez : React Testing Library nous permet de gérer ça. Il y a un moyen plus propre que nous verrons un peu plus tard, mais pour l'instant, contentons-nous de wrapper le composant Footer avec ThemeProvider directement dans notre test :

import Footer from './'
import { render } from '@testing-library/react'
import { ThemeProvider } from '../../utils/context'

describe('Footer', () => {
    test('Should render without crashing', async () => {
        render(
            <ThemeProvider>
                <Footer />
            </ThemeProvider>
        )
    })
})

Et bravo ! Ainsi le retour de notre test est tout vert.

Profitons-en pour aller un peu plus loin dans ce que l'on teste dans notre composant.

Testez les événements de vos composants :

Je vous ai déjà parlé du fait que la philosophie React Testing Library est de se mettre dans la peau de votre utilisateur.

Alors comment faire pour tester que notre NightModeButton fonctionne bien ? Ici, il n'est pas question de vérifier le state interne de notre Contexte. Au lieu de ça, regardons ce que l'utilisateur voit.

Le bouton affiche "☀️" lorsque nous sommes en mode jour et "🌙" pour le mode nuit. Il faut donc que l'on vérifie ce qui est affiché.

On va donc récupérer le contenu de notre bouton, et comparer le texte affiché - allons-y !

Interagissez avec un élément :

La bibliothèque met à jour toute une série de sélecteurs permettant d'accéder à un élément spécifique (comme en JavaScript). N'hésitez pas à jeter un oeil à la documentation de React Testing Library (en anglais) pour découvrir tous les sélecteurs auxquels vous avez accès.

Vous pouvez sélectionner un élément selon son rôle, son label, son placeholder, etc. Dans notre cas, notre Footer ne contient qu'un seul bouton. On peut donc très simplement utiliser getByRole.

On va également avoir besoin de screen qu'on importe avec render :

import { render, screen } from '@testing-library/react'

C'est quoi encore ça, screen ? Eh bien, screen est en quelque sorte le body qui contient notre composant, à partir duquel on va pouvoir utiliser nos sélecteurs.

Pour accéder à notre bouton, on a donc :

test('Change theme', async () => {
    render(
        <ThemeProvider>
            <Footer />
        </ThemeProvider>
    )
    const nightModeButton = screen.getByRole('button')
})

Et à partir de là, les choses sérieuses commencent !

Comme précédemment, on va comparer ce qui est attendu avec ce qui se passe vraiment. Alors réfléchissons.

Au départ, notre thème est récupéré. Sa valeur initiale est light : le "☀️" est affiché. Lorsqu'on clique sur le bouton, la valeur du thème change (avec toggleTheme), et le thème devient dark. Le bouton affiche alors "🌙". Un bon test est de :

  1. Vérifier la présence de "☀️".

  2. Cliquer sur le bouton.

  3. Vérifier s'il y a bien "🌙".

Commençons donc par la première étape, la présence de "☀️".

test('Change theme', async () => {
    render(
        <ThemeProvider>
            <Footer />
        </ThemeProvider>
    )
    const nightModeButton = screen.getByRole('button')
    expect(nightModeButton.textContent).toBe('Changer de mode : ☀️')
})

En vérifiant notre terminal, ce test fonctionne bien.

Information

D'ailleurs, n'hésitez pas textContent pour casser votre test et voir ce qui se passe.

On passe donc à l'étape 2, et on peut enchaîner directement avec l'étape 3 puisqu'elle ressemble fortement à l'étape 1.

Pour interagir avec notre composant, on a besoin de fireEvent qui va nous permettre de déclencher des événements du DOM, ici click. On fait donc :

import { render, screen, fireEvent } from '@testing-library/react'
import { ThemeProvider } from '../../utils/context'
import Footer from './'

test('Change theme', async () => {
    render(
        <ThemeProvider>
            <Footer />
        </ThemeProvider>
    )
    const nightModeButton = screen.getByRole('button')
    expect(nightModeButton.textContent).toBe('Changer de mode : ☀️')
    fireEvent.click(nightModeButton)
    expect(nightModeButton.textContent).toBe('Changer de mode : 🌙')
})

Bravo à vous ! Vous venez de tester avec succès votre composant Footer !

Information

Ici, nous avons utilisé le sélecteur getByRole. Ce sélecteur peut dans beaucoup de cas vous permettre d'accéder à votre élément, d'autant que vous pouvez lui passer un paramètre pour cibler encore plus précisément un élément. Mais dans le cas où vous ne pouvez pas l'utiliser, et où aucun autre sélecteur ne vous permet de cibler votre élément, vous pouvez tout à fait passer data-testid à votre composant et ensuite y accéder avec le sélecteur getByTestId. Je vous laisse regarder la documentation de React Testing Library par vous-même (en anglais).

Voyons maintenant quelques méthodes supplémentaires pour faire des tests :

On va vérifier dans Home/index.test.js que le titre de la page s'affiche bien :

import { MemoryRouter } from 'react-router-dom'
import { render, screen } from '@testing-library/react'
import Home from './'
import { ThemeProvider } from '../../utils/context'

describe('The home component', () => {
    it('should render title'; () => {
        render(
            <MemoryRouter>
                <ThemeProvider>
                    <Home />
                </ThemeProvider>
            </MemoryRouter>
        )
        expect(
            screen.getByRole('heading', {level: 2, 'text: Récupérez vos besoins, on s'occupe du reste, avec les meilleurs talents' })
        ).toBeTruthy()
    })
})
Information

Le composant <MemoryRouter> utilisé précédemment, importé de React Router, nous permet d'inclure le router dans les tests. Vous pourrez trouver plus d'information dans la documentation.

Testez vos hooks :

Précédemment, vous avez appris à créer vos propres hooks personnalisés. Mais comment tester ses hooks ?

Il pourrait être tentant de les tester indépendamment, mais la plupart du temps, ça ne correspond pas à la philosophie de React Testing Library. En effet, ces tests peuvent être considérés comme des "tests de détails d'implémentation", qui prennent du temps, pour ne pas être forcément très pertinents, précisément ce qu'on cherche à éviter.

De toute manière, ici, vous avez déjà testé un de vos hooks sans vous en rendre compte. Allez-y, lancez la commande yarn test -- --coverage pour voir.

Voilà un screenshot du coverage.

Vous voyez que votre fichier utils/hooks/index.jsx est effectivement testé (le pourcentage est bas, car le hook qui est dans le même fichier useFetch n'a pas été testé). En effet, votre composant Footer utilise le hook useTheme pour accéder à theme et toggleTheme. Et la plupart du temps, c'est la méthode à privilégier.

Information

Si vous souhaitez en savoir plus sur ce sujet, ou tout de même savoir comment tester des hooks, je vous conseille de lire cet article (en anglais) du créateur de React Testing Library.

La leçon à retenir est donc que pour tester un hook, le meilleur moyen est de tester un composant qui utilise ce hook.

Allez plus loin dans vos tests :

Qu'il s'agisse de Jest ou de React Testing Library, ces outils sont très puissants : ils permettent de simuler à peu près ce que l'on veut.

Dans notre application Shiny, la majeure partie de notre contenu vient des données que l'on récupère depuis une API (comme on aurait récupéré notre contenu depuis un CMS). Les calls API ne dérogent pas à la règle : les datas peuvent être simulées dans nos tests et on appelle ça des mocks (des simulations) ! Nous allons donc pouvoir tester nos autres composants.

Testez des composants qui font des calls API :

Installez msw pour faire vos simulations de calls API :

Pour pouvoir simuler nos calls API, un peu de configuration s'impose à nous. Si, comme moi, vous n'aimez pas la config, ne vous inquiétez pas : je vous promets que ça ne durera pas trop longtemps !

Pour faire nos mocks, React Testing Library recommande d'utiliser une bibliothèqye externe : MSW (pour Mock Service Worker), hébergée sur GitHub. On commence donc par installer la bibliothèque :

yarn add msw --dev

Pour faire simple, la biliothèque msw va permettre d'intercepter les calls API que font vos composants lors des tests. Et fonc, elle permet de simuler ce qui aurait été retourné, sans même que votre application ait conscience de quoi que ce soit.

Créez vos mocks :

Pour cela, dans chacun de nos fichiers de test, on va devoir configurer un "server", qui va s'occuper de l'interception des calls API. Lançonnous dès maintenant dans le test du composant de la page Freelances/index.jsx. On crée donc un fichier dans /pages/Freelances, qu'on appelle index.test.js.

On va avoir besoin de rest depuis msw. On fait donc :

import { rest } from 'msw'
import { setupServer } from 'msw/node'
import { render, waitFor, screen } from '@testing-library/react'

import Freelances from './'

const server = setupServer(
    // On précidr ivi l'url qu'il faudra "intercepter"
    rest.get('http://localhost:8000/freelances', (req, res, ctx) => {
        // Là on va pouvoir passer les datas mockées dans ce qui est retourné en json
        return res(ctx.json({}))
    })
)

// Active la simulation d'API avant les tests depuis server
beforeAll(() =>server.listen())
// Réinitialise tout ce qu'on aurait pu ajouter en termes de durée pour nos tests avant chaque test
afterEach(() => server.resetHandlers())
// Ferme la simulation d'API une fois que les tests sont finis
afterAll(() => server.close())
Information

Ici, beforeAll(), beforeEach() et afterEach n'ont pas besoin d'être importés au même titre que test et describe : ils font partie de l'environnement global de Jest. Vous ne trouverez égalemnt dans la documentation des Globals de Jest. Comme leur nom l'indique :

  • beforeAll est exécuté avant les tests;

  • beforeEach est exécuté avant chaque test;

  • et afterEach est exécuté après chacun des tests.

Et voilà, c'est tout pour la configuration !

Mais... elle est où la data qu'on renvoie ?! Très bien vu. Je ne l'avais pas encore mise. Il nous faut un format qui corresponde à ce que l'API nous renvoie. So vpis faites un console.log de ce que http://localhost:8000/freelances vous retourne, vous verre que c'est une liste d'objets. On crée donc une liste d'objets pour notre mock :

const freelancersMockedData = [
    {
        name: 'Harry Potter',
        job: 'Magicien fontend',
        picture: '',
    },
    {
        name: 'Hermione Granger',
        job: 'Magicienne fullstack',
        picture: '',
    },
]

... qu'on retourne dans notre mock :

const server = setupServer(
    // On précidr ivi l'url qu'il faudra "intercepter"
    rest.get('http://localhost:8000/freelances', (req, res, ctx) => {
        // Là on va pouvoir passer les datas mockées dans ce qui est retourné en json
        return res(ctx.json({ freelancersList: freelancersMockedData }))
    })
)

Tout est prêt ! C'est le moment d'utiliser tout ça.

Exploitez vos mocks :

Notre configuration est prête. On va pouvoir l'utiliser pour tester pages/Freelances/index.jsx.

Mais avant de la tester, arrêtons-nous quelques instants sur notre stratégie de tests. Notre composant affiche un loader pendant qu'il fait la requête à l'API, puis affiche les données dans des composants Cards. Dans un premier temps, on peut donc vérifier que :

  1. Le loader s'affiche bien pendant le call.

  2. Notre première Card affiche bien les éléments récupérées dans le call avec le premier élément.

Commençons par nous occuper de la première étape. On sait que isLoading est initialisé à true, donc on peut tout simplement vérifier que notre Loader apparaît bien.

Mais d'ailleurs, on fait comment ? Notre Loader est une simple div stylisée, il ne contient pas de texte ? Je vous l'avais mentionné précédemment, c'est maintenant le moment d'utiliser data-testid. On va tout simplement le préciser dans Freelances/index.jsx :

<Loader theme={theme} data-testid="loader" />
```
Et dans notre test, on le récupère avec :
```
test('Should render without crash', async () => {
    render(
        <ThemeProvider>
            <Freelances />
        </ThemeProvider>
    )
    expect(screen.getByTestId('loader')).toBeTruthy()
})

Votre test se lance. Ça fonctionne.

C'est bien beau, "ça fonctionne", mais qu'est-ce qui me dit que mon test n'est pas toujours bon ? Que ça fonctionne quoi qu'il arrive ? Essayez de changer l'id pour vous assurer que ça casse bien :

expect(screen.getByTestId('cetIdCorrespondÀRien')).toBeTruthy()

Notre test échoue bien.

On va maintenant tester avec nos datas. Pour cela, on va avoir besoin de waitFor que je vous ai déjà importer juste avant depuis '@testing-library/react'. Cette méthode permet de gérer du code asynchrone, comme pour un call API, par exemple. On n'oublie d'ailleurs pas d'ajouter un async devant le callback de notre test.

On va vérifier ici que notre code affiche bien les noms "Harry Potter" et "Hermione Granger". Pour cela, on a :

it('Should display freelancers names', async () => {
    render(
        <ThemeProvider>
            <Freelances />
        </ThemeProvider>
    )
    expect(screen.getByTestId('loader')).toBeTruthy()
    await waitFor(() => {
        expect(screen.getByText('Harry Potter')).toBeTruthy()
        expect(screen.getByText('Hermione Granger')).toBeTruthy()
    })
})

Encore une fois, vous pouvez modifier le texte, avec par exemple :

expect(screen.getByText('Harry PotDeBeurre')).toBeTruthy()

Et le test échoue !

Maintenant que nous avons vu les mocks et une stratégie pour les tests, on se retrouve juste en dessous pour un autre exemple :

Grâce à la méthode waitForElementToBeRemoved, qui est une fonction asynchrone qui attend que notre élément soit retiré du DOM avant d'exécuter la suite, on va remplacer le code précédent par ceci :

it('Should display freelancers names', async () => {
    render(
        <ThemeProvider>
            <Freelances />
        </ThemeProvider>
    )
    await waitForElementToBeRemoved(() => screen.getByTestId('loader'))
    await waitFor(() => {
        expect(screen.getByText('Harry Potter')).toBeTruthy()
        expect(screen.getByText('Hermione Granger')).toBeTruthy()
    })
})

Personnalisez votre render :

Bon, depuis tout à l'heure je vous fais utiliser votre Theme directement dans vos tests. Ce n'est pas très propre. Surtout que si vous testez d'autres composants tels que Home, vous aurez aussi à le wrapper dans le router.

Mais ça tombe bien, puisque la fonction render de React Testing Library peut prendre en paramètre un wrapper.

Dans le test qu'on a fait juste au-dessus, on va donc déclarer un nouveau composant React Wrapper :

function Wrapper({ children }) {
    return <ThemeProvider>{children}</ThemeProvider>
}

Et on le réutilise en le passant en paramètre de notre render :

render(<Freelances />, { wrapper: Wrapper })

Et voilà !

On peut même en faire un outil qu'on pourra réutiliser dans tous nos tests.

Pour cela, on crée un dossier /test dans /utils et on y met un fichier index.js afin d'y mettre notre outil qui servira pour tous nos tests.

Ce qui nous donne :

import { render as rtlRender } from '@testing-library/react'
import { ThemeProvider } from '../../utils/context'

function Wrapper({ children }) {
    return <ThemeProvider>{children}</ThemeProvider>
}

export function render(ui) {
    rtlRender(ui, { wrapper: Wrapper })
}

Il ne nous reste qu'à importer notre nouveau render dans notre /pages/Freelances/index.test.js, et à supprimer rendre des imports depuis '@testing-library/react'.

... Et ça fonctionne comme on le souhaite !

Puisqu'on en est là, autant en profiter pour gérer notre Router et notre SurveyProvider aussi. Si vous voulez explorer différentes options (et si vous avez besoin besoin de faire des tests en ayant accès à notre history), vous pouvez regarder la documentation de React Testing Library (en anglais).

Mais dans notre cas, on veut juste que nos tests fonctionnent, même lorsqu'il y a des Link dans nos composants. On transforme donc notre fichier pour ajouter notre Router et SurveyProvider :

import { render as rtlRender } from '@testing-library/react'
import { ThemeProvider } from '../../utils/context'

function Wrapper({ children }) {
    return (
        <MemoryRouter>
            <ThemeProvider>
                <SurveyProvider>{children}</SurveyProvider>
            </ThemeProvider>
        </MemoryRouter>
    )
}

export function render(ui) {
    rtlRender(ui, { wrapper: Wrapper })
}

En lançant le test, tout fonctionne comme prévu !

Découvrez d'autres types de tests :

Vous avez appris à créer des tests unitaires avec Jest et à teser vos composants avec React Testing Library. Vous avez testé vos interactions et pu simuler des appels API. Mais vous vous en doutez peut-être : le sujet des tests est très vaste. Il existe de nombreux outils et de nombreuses approches.

Ci-dessous, je vous fais la démo d'une approche que nous n'avons pas vue ensemble.

L'idée des snapshots est plutôt simple : le test vient faire une capture de ce qui est rendu par notre composant et, quand on fait une modification de code, le test compare le snapshot qui a été pris avec la nouvelle version obtenue. Cela permet de nous signaler ce qui change quand on fait une modification de code et donc de vérifier que rien de critique n'est caché sans que l'on s'en rende compte. On va le faire le test sur la page "Freelances" pour les messages d'erreur venant de l'API

Dans le useEffect du hook personnalisé useFetch dans le fichier /hooks/index.jsx, on a le code suivant :

useEffect(() => {
    if (!url) return
    setLoading(true)
    async function fetchData() {
        try {
            const response = await fetch(url)
            if (!response.ok) {
                const { errorMessage } = await response.json()
                throw new Error(errorMessage)
            } else {
                const data = await response.json()
                setData(data)
            }
        } catch (err) {
            setError(err.message)
        } finally {
            setLoading(false)
        }
    }
    fetchData()
}, [url])

Donc, dans notre fichier /pages/Freelances/index.jsx, on a le code ci-dessous :

const { data, isLoading, error } = useFetch(`http://localhost:8000/freelances`)

if (error) {
    return <span data-testid="error">{error}</span>
}

Enfin, dans le fichier /pages/Freelances/index.test.js, on a ceci :

it('Should display error content', async () => {
    server.use(
        rest.get('http://localhost:8000/freelances', (req, res, ctx) => {
            return res.once(
                ctx.status(500,
                ctx.json({
                    errorMessage: `Oups il y a eu une erreur dans l'API`,
                }))
            )
        })
    )
    render(<Freelances />)
    await waitForElementToBeRemoved(() => screen.getByTestId('loader'))
    expect(screen.getTestId('error')).toMatchInlineSnapshot()
})

On lance le test avec yarn run test et, une fois le test totalement exécuté, on peut voir dans /pages/Freelances/index.test.js le contenu de notre élément s'est rajouté dedans.

Information

Juste au-dessus, nous avons appris à manier des snapshots. Si vous voulez en apprendre davantage, n'hésitez pas à jeter un oeil à la documentation officielle de Jest sur les snapshots, et à cet article de blog (en anglais).

Je vous ai également mentionné les tests end-to-end. Nous ne les avons pas vus ensemble, mais ils constituent un outil très puissant. À l'heure actuelle, j'aurais tendance à vous conseiller d'utiliser la bibliothèque Cypress.

Comme pour tout le reste en JavaScript, les outils de tests évoluent rapidement. Pour rester à la page, je vous conseille de faire une veille, puis la documentation des nouveaux outils.

Apprivoisez les anciennes syntaxes de React :

À l'heure où ce cours a été écrit, nous en sommes à la version 17.0.2 de React. React a effectivement connu de nombreuses évolutions. Cela veut aussi dire que la manière dont on écrit un composant aujourd'hui ne ressemble pas du tout à la manière dont on le faisait au début. On pourrait considérer que ce qui appartient au passé reste dans le passé. Mais ce serait sans compter les codebases qui contiennent des composants écrits dans d'autres syntaxes. Remontons dans notre machine à remonter dans le temps pour voir les grandes étapes de React.

Découvrez les évolutions principales de React :

Au tout début : createClass :

Lorsque React a été rendu open source en 2013, les composants étaient créés avec React.createClass. Pour vous donner un exemple, un composant MyComponent reçoit une prop title se serait écrit de la manière suivante :

React.createClass({
    displayName: "MyComponent",
    render() {
        return (
            <div>
                <h1>{this.props.title}</h1>
            </div>
        )
    }
})

Mais aujourd'hui, cette syntaxe est officiellement considérée comme dépréciée, et vous ne la trouvez nulle part dans la documentation de React.

... En revanche, vous risquez clairement de tomber sur des composants classe.

Le temps des composants classe :

Avec l'ajout des classes dans ES 2015 (voici la documentation de Mozilla sur les classes, en anglais), React a voulu s'aligner en créant les composants classe. Il s'agissait à l'époque d'un changement majeur. Dans la suite de ce cours, nous allons prendre le temps de revenir sur leur syntaxe, la manière de faire des appels API et de gérer le state et les props dans les composants classe.

Découvrons ci-dessous la syntaxe des composants classe. On va transformer notre composant fonction Card en composant classe comme ceci :

import { Component } from 'react'
import PropTypes from 'prop-types'
import DefaultPicture from '../../assets/profile.png'
import { CradWrapper, CardLabel, CardImage, CardTitle } from './style'

/* function Card({ label, title, picture, theme }) {
    return (
        <CardWrapper theme={theme}>
            <CardLabel theme={theme}>{label}</CardLabel>
            <CardImage src={picture} alt="freelance" />
            <CardTitle theme={theme}>{title}</CardTitle>
        </CardWrapper>
    )
} */

class Card extends Component {
    constructor(props) {
        super(props)
        this.state = {}
    }

    render() {
        const { theme, label, picture, title } = this.props
        return (
            <CardWrapper theme={theme}>
                <CardLabel theme={theme}>{label}</CardLabel>
                <CardImage src={picture} alt="freelance" />
                <CardTitle theme={theme}>{title}</CardTitle>
            </CardWrapper>
        )
    }
}

Card.propTypes = {
    label: PropTypes.string.isRequired,
    title: PropTypes.string.isRequired,
    picture: PropTypes.string.isRequired,
}

Card/defaultProps = {
    label: '',
    title: '',
    picture: '',
}

export default Card

Découvrez la syntaxe :

Comme vous avez pu le voir juste au-dessus, la syntaxe des composants classe est différente. Nous allons ici créer un nouveau composant EmailInput que nous mettrons dans le footer.

On commence d'abord par déclarer le composant :

import { Component } from 'react'

class EmailInput extends Component {
    constructor() {
    }

    render() {
        return (
            <div>
                <input />
            </div>
        )
    }

}

export default EmailInput

class et extends Component sont ici la manière de déclarer cotre composant. Si vous avez déjà manipulé des classes, vous devriez déjà avoir vu extends et constructor. Mais sachez que le constructor, dont vous trouverez la documentation ici, est une méthode qui est utilisée pour créer et initialiser un objet lorsqu'on utilise le mot clé class.

Mais au fait, c'est quoi ce render ?! Eh bien, il s'agit d'une méthode de votre composant. C'est d'ailleurs la seule méthode qui est obligatoirement appelée dans votre composant (et donc qui doit impérativement y figurer). Pour un certain nombre d'événements, votre composant devra être "re-render". À chaque fois, cette méthode va recalculer ce qui y est déclaré, et afficher ce qui est retourné.

Il y aura un nouveau render à chaque fois qu'une mise à jour aura lieu :

  • à l'initialisation du constructor;

  • à chaque mise à jour d'une props;

  • à chaque changement du state (mais nous y reviendrons plus tard).

Effectivement, vous n'avez pas de render dans le composants fonction.

Notez que render doit forcément retourner du JSX (ce qui sera dans le return). Mais si vous n'avez rien à retourner dans votre render, vous pouvez également retourner null.

Accédez aux props :

Nous allons ajouter un peu de style au composant que vous venons de créer, en faisant une version pour le darkMode et une version pour le lightMode. Sauf que, comme je vous l'ai dit précédemment, utiliser le Contexte est beaucoup plus simple avec les hooks... Or, on ne peut pas utiliser les hooks depuis les composants classe. On va donc se contenter le theme en props.

Mais d'ailleurs, ici, on n'a pas de paramètres dans lesquels récupérer les props ? On fait comment pour les récupérer ? Vous vous en doutez sûrement, il existe également un moyen de les récupérer dans les composants classe. Pour cela, vous pouvez utiliser this.props partout dans votre composant - que ce soit dans le return, le render, ou dans une méthode. On va d'abord les initialiser dans le constructor, on en profite pour lui passer le state initial, même si nous n'en avons pas besoin pour le moment (sinon, notre linter va râler).

On déclare donc dans notre composant :

import { Component } from 'react'

class EmailInput extends Component {
    constructor(props) {
        super(props)
        this.state = {
            inputValue: '',
        }
    }

    render() {
        // Ici on récupère theme en destructurant this.props
        const { theme } = this.props
        return (
            <div>
                <input />
            </div>
        )
    }

}

export default EmailInput

Ce qui va nous permettre de faire un style différent en fonction de notre theme.

Mais au fait, c'est quoi ce this, là ? Si vous êtes familier de JavaScript, vous connaissez sûrement déjà this. this fait référence à l'objet auquel il appartient.

Information

Si vous essayez de faire un console.log de this dans un composant fonction, vous verrez qu'il est undefined. Pour en apprendre davantage sur le this, vous pouvez jeter un oeil à la documentation.

Donc, ici, le this fait référence à notre composant : il s'agit des props et du state de EmailInput.

Mais attention, lorsque vous déclarez des méthodes (qui sont en quelque sorte des fonctions) dans vos composants, il vous faudra faire attention au bind.

C'est quoi encore ça, le "bind" ? Déjà, commençons simple : "bind" en traduction littérale veut dire "lier". Cela veut dire qu'on va lier nos méthodes à notre composant classe : il faut qu'elles soient correctement bindées au this. C'est ce que nous verrons dans quelques instants, une fois que nous aurons utilisé le state.

Gérez le state avec setState :

Nous allons maintenant gérer la valeur qui est saisie dans notre EmailInput avec setState.

setState... C'est un peu la même chose que useState, non ? Eh bien... non. Tout d'abord, useState est un hook : il s'agit donc de la manière de gérer le state pour les composants fonction. setState concerne les composants classe. Par ailleurs, si ces deux fonctions concernent toutes les deux le state, elles ne fonctionnent pas de la même manière :

  • useState vous permet de déclarer votre variable de state, d'initialiser sa valeur et de récupérer une fonction pour la mettre à jour;

  • mais setState permet uniquement de mettre à jour tout le state de notre composant. Souvenez-vous : le state initial est déclaré dans le constructor.

Pour gérer le state de la valeur saisie dans notre EmailInput, nous allons devoir procédér par étapes.

Juste au-dessus, vous aviez initialisé votre state dans le constructor avec :

this.state = {
    inputValue: '',
}

On va maintenant pouvoir déclarer une fonction pour mettre à jour la valeur de notre state :

updateInputValue = (value) => {
    this.setState({ inputValue: value })
}

Pas besoin de const = devant notre fonction. D'habitude, il aurait fallu l'initialiser, mais ici, étant donné que vous êtes dans une classe, updateInputValue est une méthode de votre classe EmailInput.

On aurait pu mettre updateInputValue dans le render, non ? Eh bien, oui. On aurait pu. Mais comme je vous disais, tout ce qui est dans le render est exécuté à chaque fois qu'une prop, ou que le state, est mis à jour. Ce qui veut dire que notre fonction serait à nouveau déclarée : pas très performant, n'est-ce pas ?

Et cette méthode est appelée dans onChange de input :

<input
    onChange={(e) => this.updateInputValue(e.target.value)}
/>

Ici, chaque fois qu'un setState est effectué, cela va déclencher un nouveau render de notre composant.

Important

Puisque setState déclenche un rerender de votre composant, vous ne pouvez jamais appeler setState depuis render, sinon, cela provoquera une boucle infinie dans votre composant.

Il existe un certain nombre de règles à respecter pour setState (le fait que setState soit asynchrone, que les mises à jour du state soient fusionnées, etc.). Je vous conseille donc de vous renseigner sur ces règles directement dans la documentation de React.

Et de la même manière qu'on a accédé aux props avec this.props, c'est this.state qui nous permet d'accéder au state courant.

Pour afficher le contenu de notre input, on peut donc faire :

render() {
    return (
        <div>
            {this.state.inputValue}
            <input
                onChange={(e) => this.updateInputValue(e.target.value)}
            />
        </div>
    )
}

Revenez au this :

D'habitude, lorsque nous déclarons une nouvelle fonction, on utilise la syntaxe function myFunction(). Alors pourquoi ici on a utilisé une fonction fléchée ? Eh bien, pour pouvoir accéder à votre méthode dans votre classe, vous avez besoin de "binder" votre fonction à votre classe, relier les deux, en quelque sorte. Les fonctions fléchées permettent de le faire de manière implicite. Donc pas d'embêteemnt ici.

Information

Ça ne vous provoquera pas systématiquement une erreur si vous ne bindez pas votre fonction au this systématiquement, mais il faut clairement éviter de le faire car cela PEUT provoquer une erreur très facilement.

Sans fonction fléchée, autrement, vous auriez dû le faire de manière explicite comme ci-dessous :

constructor(props) {
    super(props)
    this.updateInputValue = this.updateInputValue.bind(this)
    this.state = {
        inputValue: '',
    }
}

updateInputValue (value) {
    this.setState({ inputValue: value })
}

Je sais que le this peut être un peu complexe à saisir. C'est d'ailleurs une des raisons pour lesquelles React a choisi de favoriser les composants fonction, pour éviter cette complexité au moment de l'apprentissage. Mais n'hésitez pas à creuser un peu le sujet si cela reste trop flou pour vous. Par exemple, cet article de blog (en anglais) revient sur quelques notions fondamentales.

Affichez les données d'une API dans un composant classe :

Entre le moment où ils sont générés dans le DOM, et le moment où ils en sont retirés, les composants passent par différentes étapes. Dans les composants classe, vous avez une liste d'étapes qui correspondent à un moment précis du cycle de vie, dans lesquelles vous pouvez effectuer des actions.

On parle de méthodes de cycle de vie.

Découvrez les méthodes de cycle de vie :

Juste en dessous, vous aurez une petite démonstration des différentes étapes que connaît un composant, et auxquelles vous pouvez accéder.

Pour la démonstration, j'ai créé un nouveau composant MyComponent qui va être affiché dans Home selon le state display. Notre nouveau composant possède le contenu suivant :

import { Component } from 'react'

export default class MyComponent extends Component {

    render() {
        console.log('===== render =====')
        return (
            <div
                style={{
                    backgroundColor: 'red',
                    height: 30,
                    marginBottom: 20,
                    textAlign: 'center',
                }}
            >
                Mon composant
            </div>
        )
    }
}

La méthode du cycle la plus évidente et qui est obligatoire est le render.

La méthode componentDidMount() est appelé juste après que le composant a été monté dans le DOM, donc juste après le premier render :

componentDidMount() {
    console.log('===== componentDidMount =====')
}

La méthode componentDidUpdate() intervient, comme son nom l'indique, juste après une mise à jour du state ou des props :

componentDidUpdate(prevProps, prevState) {
    console.log('===== componentDidUpdate =====')
}

La méthode explicite componentWillUnmont() intervient juste avant la mort en quelque sorte du composant, lorsqu'il va être retiré du DOM :

componentWillUnmont() {
    console.log('===== componentWillUnmont =====')
}

Alors est-ce que ça vous paraît plus clair ?

Information

Dans les composants fonction que vous connaissez davantage, vous avez appris à déclencher une action juste après que le composant a été généré dans le DOM avec useEffect et en passant un tableau de dépendances vide.

Pour vous faire un petit résumé des différentes étapes auxquelles vous avez accès dans les composants classe :

Voilà un diagramme des méthodes de cycle de vie.

Comme vous l'avez vu juste en haut :

  • Le constructeur est invoqué, comme pour n'importe quel objet, lorsque le composant apparaît pour la première fois dans un DOM virtuel... Il reçoit les props initiales en argument.

  • Puis vient le render. C'est ensuite le moment où est appelé componentDidMount (une fois que le composant est monté sur le DOM).

  • Après, s'il y a une mise à jour et que le composant est re-render, componentDidUpdate est appelé.

  • Et juste avant que le composant soit retiré du DOM, c'est au tour de componentWillUnmount d'être appelé.

Il existe d'autres méthodes de cycle de vie, mais vous aurez moins souvent besoin de celles-ci. Vous pourrez en apprendre davantage dans la documentation de React.

Important

En ce qui concerne les méthodes de cyclique de vie, il existe un certain nombre de méthodes qui ont été dépréciées au fil du temps, telles que "componentWillMount". Vous verrez que depuis quelques années, ces méthodes dépréciées ont été renommées pour éviter qu'on les utilise. Ainsi, componentWillMount s'appelle désormais UNSAFE_componentWillMount(). Ça ne donne pas vraiment envie de l'utiliser, non ? Dans les dernières versions, elles ne fonct même plus partie de React.

Appelez une API dans un composant classe avec componentDidMount :

C'est le moment de mettre tout ça en application en appelant notre API Shiny dans un nouveau composant classe. Pour l'occassion, nous allons créer un nouveau composant qui permet d'afficher plus d'informations sur un freelance lorsqu'on clique sur la Card.

Nous allons commencer par permettre de naviguer sur http://localhost/profile/:id. Dans le fichier index.jsx à la racine de /src, nous avons ajouté une route pour bien rediriger l'utilisateur vers le profil du freelance :

...
<Route path="/freelances" element={<Freelances />} />
<Route path="/profile/:id" element={<Profile />} />
<Route path="*" element={<Error />} />
...

On enchraîne ensuite en permettant de naviguer sur la page profile en ajoutant un lien autour de la Card dans /pages/Freelances/index.jsx. Pour cela, on fait donc :

<CardsContainer>
    {freelancersList?.map((profile) => (
        <Link key={`free-lance-${profile.id}`} to={`/profile/${profile.id}`}>
            <Card
                label={profile.job}
                title={profile.name}
                picture={profile.picture}
                theme={theme}
            />
        </Link>
    ))}
</CardsContainer>

Et on oublie pas de supprimer la fonctionnalité de favoris dans le composant classe (pour ne pas avoir des étoiles ajoutées inutilement). Il ne nous reste plus qu'à développer pages/Profile/index.jsx elle-même !

On va commencer par récupérer l'id du freelance dont on veut afficher le profil dans les paramètres. Mais oh oh... Comment on va faire ?

Comme on l'avait fait pour la page Survey : il nous suffit d'utiliser useParams, non ?! Eh bien non ! Souvenez-vous : les hooks sont uniquement accessibles depuis les composants fonction. D'ailleurs, la version 6 de React Router, sortie en 2021, soit 2 ans après la mise en place des hooks, est pensée pour être utilisée dans des composants fonctions. Nous avons donc ici 2 options :

  1. Transformer le composant Profile en composant de type fonction.

  2. Créer un composant qui sera parent de Profile et qui pourra récupérer les paramètres.

Comme nous souhaitons pour l'exemple créer notre composant Profile en Classe nous allons donc opter pour la deuxième option.

On va donc devoir changer la déclaration de notre route. Dans /src/index.jsx, on fait donc :

<Route
    path="/profile/:id"
    element={<ProfileContainer />}
/>

Nous créons donc notre composant ProfileContainer que nous ajouterons à notre dossier components. Nous aurons donc un nouveau fichier index.jsx dans le dossier src/components/ProfileContainer avec ce code :

import { useParams } from 'react-router-dom'
import Profile from '../../pages/Profile'

function ProfileContainer() {
    const { id } = useParams()
    return <Profile id={id} />
}

export default ProfileContainer

Ensuite nous pourrons récupérer l'id dans notre classe Profile comme n'importe quelle autre props :

import { Component } from 'react'

class Profile extends Component {
    render() {
        const { id } = this.props
        return <div><h1>Freelance : {id}</h1></div>
    }
}

export default Profile

Notre paramètre s'affiche bien.

Passons maintenant aux choses sérieuses en lançant notre appel API dans la méthode de cycle de vie componentDidMount(). Encore une fois, nous allons devoir nous passer de notre hook useFetch puisque nous sommes dans un composant classe.

On commence donc par notre constructor. Ici, si vous regardez l'API, vous verrez que nous allons récupérer un objet profileData; il nous faudra donc profileData dans notre state.

constructor(props) {
    super(props)
    this.state = {
        profileData: {},
    }
}

Pour le fetch, vous pouvez réutiliser le code que vous aviez dans useFetch, version Promise. On le met tout simplement dans componentDidMount(), ce qui nous donne :

componentDidMount() {
    const { id } = this.props

    fetch(`http://localhost:8000/freelance?id=${id}`)
    .then((response) => response.json())
    .then((jsonResponse) =>: {
        this.setState({ profileData: jsonResponse?.freelanceData })
    })
}

Et... Ça fonctionne bien !

Information

Si vous aviez voulu utiliser la syntaxe async / await, ça aurait donné ça :

componentDidMount() {
    const { id } = this.props
    const fetchData = async () => {
        const response = await fetch(`http://localhost:8000/freelance?id=${id}`)
        const jsonResponse = await response.json()
        if (jsonResponse && jsonResponse.freelanceData) {
            this.setState({ profileData: jsonResponse?.freelanceData })
        }
    }
    fetchData()
}

Il ne reste plus qu'à afficher ce qui nous est retourné par l'API. Et voilà notre composant :

import { Component } from 'react'

class Profile extends Component {
    constructor(props) {
        super(props)
        this.state = {
            profileData: {},
        }
    }

    componentDidMount() {
        const { id }  this.props

        fetch(`http://localhost:8000/freelance?id=${id}`)
        .then((response) => response.json())
        .then((jsonResponse) > {
            this.setState({ profileData: jsonResponse?.freelanceData })
        })
    }

    render() {
        const { profileData } = this.state
        const {
            picture,
            name,
            location,
            tjm,
            job,
            skills,
            available,
            id,
        } = profileData

        return (
            <div>
                <img src={picture} alt={name} height={150} width={150} />
                <h1>{name}</h1>
                <span>{location}</span>
                <h2>{job}</h2>
                <div>
                    {skills &&
                        skills.map((skill) => (
                            <div key={`skill-${skill}-${id}`}>{skill}</div>
                        ))}
                </div>
                <div>{available ? 'Disponible maintenant' : 'Indisponible'}</div>
                <span>{tjm} € / jour</span>
            </div>
        )
    }
}

export default Profile

Et voilà ! Vous avez un tout nouveau composant Profile qui permet d'afficher le profil de vos freelances, et le tout écrit avec un composant classe.

Découvrez le state management :

Revenez sur les notions dde state et de props :

Vous rappelez-vous comment créer un composant réutilisable et configurable ?

Revoyons brièvement la différence entre les props et le state, ce qu'ils nous permettent de faire et leurs limites.

En réutilisant nos composants, nous souhaitons la plupart du temps adapter leur comportement selon leur emplacement dans l'application ou selon certaines données provenant du composant parent. Nous utilisons donc à cet effet les props, qui seront appliqués à nos composants.

const IMAGES = [
    {
        url: "https://unsplash.com/fr/photos/cjSUZMA2iW8",
        title: "Un cheval"
    },
    {
        url: "https://unsplash.com/fr/photos/1ZjI3KgB9Co",
        title: "Un chien"
    }
]

const Container = () => {
    return IMAGES.map((image, index) => <Picture url={image.url} title={image.title} key={index} />)
}

Ci-dessus, nous utilisons le composant Picture pour afficher autant d'images différentes provenant d'un tableau IMAGES en passant en props des valeurs url et title distinctes.

Certains composants évoluent au fil du temps. Ils réagissent aux évènements utilisateurs : des actions sur un bouton ou la saisie d'un nombre dans un champ, par exemple.

Le staten, ou état local du composant, nous permet de stocker à l'échelle de notre composant des valeurs qui changent et dont les changements ont une incidence sur le comportement de notre composant :

const Counter = () => {
    const [count, setCount] = useState(0)

    return <div>
        <span>Valeur du compteur: {count}</span>
        <button onClick={() => setCount(count + 1)}>ajouter</button>
    </div>
}

Ci-dessus, nous modifions la valeur d'un compteur qui change à chaque clic sur le bouton "Ajouter".

En combinant props et state, il nous est donc possible de partager des valeurs de states entre des composants faisant partie de la même branche, c'est-à-dire ayant un parent commun.

Pour accéder au state d'un parent, un composant doit recevoir en props les valeurs du state du parent. Un composant peut modifier le state de son parent si celui-ci obtient la méthode setState, qui permet de modifier le state en props. On dit que les props descendent et que l'état remonte :

Les props descendent et l'état remonte.

Ci-dessus, nous partageons la valeur du state du composant parent vers les composants A et B en passant celle-ci en props. Grâce au setState passé en props, le composant B peut modifier la valeur du state A.

Notez aussi qu'un composant A qui partage le même parent que le composant B ne peut pas accéder au state de celle-ci. C'est une contrainte, puisque faire remonter le state dans le composant parent devient ici l'unique moyen de partager le state entre A et B.

Comprenez le principe de props drilling :

Nous venons de voir que l'état peut se districbuer dans les composants par le biais des props. Du fait que deux composants ayant le même parent ne peuvent pas accéder directement à leur state respectif, nous sommes contraints de passer par le state du parent pour le distribuer dans chacun d'eux.

Ce qui suit décrit une seconde contrainte.

Comment faire pour transmettre des données à un composant qui n'est pas enfant direct d'un autre composant ? On respecte dans ce cas le sens de distribution pazrent vers enfant et on s'assure de passer les props jusqu'au composant ciblé.

Le props drilling

Ci-dessus, nous voyons un exemple de transmission à travers les composants. Notre Props A, appliqué à notre Composant A, traverse ainsi le Composant B pour être appliqué au Composant C.

Nous faisons ici ce qu'on appelle du props drilling, nous avons "percé" un passage dans le composant B pour transmettre la valeur de notre props A au composant C.

Découvrez les limites du props drilling :

Nous avons donc un moyen de partager des states dans ce cas dans notre application, pourquoi ne pas utiliser cette méthode partout ? La raison est à la fois conceptuelle et pratique.

Lorsque nous créons des composants afin de les réutiliser, nous cherchons à les rendre le plus génériques possible. C'est-à-dire suffisammebr simples et conçus de manière à être adaptés à beaucoup de situations. Nous essayons alors de limiter la responsabilité de chaque composant à ses fonctionnalités propres.

Lorsque nous utilisons le props drilling, nos composants intermédiaires (ici Composant B) doivent recevoir des props et les transmettre même si ces données ne sont pas rattachées à leur logique propre. Nous donnons ainsi à ces composants trop de responsabilités.

De plus, si nous multiplions la réutilisation de nos composants intermédiaires avec des composants enfants qui attendent des props différents à chaque fois, nous augmentons le nombre de props à faire traverser.

Le props drilling devient plus compliqué.

Ci-dessus, nous représentons la réutilisation du composant B dans 2 situations. Le composant B doit faire redescendre Props A, Props B et Props C, et ce, même s'ils ne sont pas utilisés dans tous les cas.

Information

Bien évidemment, sur un arbre qui ne contient que quelques composants, il est assez simple de distribuer ces props. Mais on imagine très vite que certaines applications demanderaient une difficile organisation de cette distribution.

On comprend très vite que mis à l'échelle dans une application qui contient des dizaines composants à faire communiquer, avec plusieurs composants intermédiaires, l'exercice peut s'avérer difficile.

Gérez le state depuis un store centralisé :

Eh oui, distribuer les valeurs via les props peut devenir un vrai casse-tête. Alors, comment gérer le state ?

À l'image d'une étagère remplie de livres dans lesquels des élèves se servent au moment voulu, peut-on imaginer stocker les données à un seul endroit et connecter chaque composant qui aurait besoin de ceux-ci ? C'est ce que propose Dan Abramow via la librairie Redux qui s'inspire de l'architecture Flux. D'autres librairies s'inspirent ou sont des alternatives de cette architecture, comme MobX ou Recoil. Vous pouvez retrouver le projet archivé de Flux sur GitHub.

Information

Dan Abramov est un développeur de la communauté open source qui a contribué fortement à la création de Redix et qui, à l'heure où ce cours s'écrit, travaille auprès de Facebook, au coeur même des contributeurs principaux de ReactJS.

Le principe de l'architecture flux est le suivant :

  • nous stockons un état général dans ce qu'on appelle des stores;

  • les composants qui ont besoin d'une valeur de ce state viennent consulter le store et s'abonner aux changements de ce state

  • le store possède un mécanisme qui à chaque changement du state, va informer chaque composant abonné qu'un changement a eu lieu;

  • chaque composant peut alors relire le contenu du state et mettre à jour son état local.

L'état centralisé simplifie la distribution d'état dans l'application React.

Ainsi nous stockons l'état à l'extérieur de notre application qui vient s'alimenter le moment voulu.

Nous reviendrons plus en détail sur cette architecture prochainement, lorsque nous mettrons en place notre propre implémentation.

Communiquez les changements de state à travers un architecture Flux :

Décortiquez l'architecture Flux :

Nous avons vu précédemment le fonctionnement général de cette architecture. Quels sont les éléments qui aident à mettre celle-ci en place et comment ils interagissent ?

On identifie ici le sens de modification du state à partir du composant.

Ci-dessus, le dispatcher centralise les changements du state.

Il reçoit ainsi des actions, ou messages, qui comportent :

  • le type de traitement à appliquer au state, l'ajout d'un produit à la liste, par exemple;

  • un payload, ou un ensemble de données servant à mettre à jour le state, admettons le nom et le prix du produit à ajouter.

Ensuite, il transmet cette action à tous les stores qui, selon leur responsabilité, vont exécuter l'action, puis notifier aux composants abonnés que leur state a été modifié.

Les composants peuvent ensuite venir récupérer les nouvelles valeurs du state et mettre à jour leur rendu.

Information

Nous verrons plus tard comment s'articule Redux, mais nous remarquerons dès ici que Flux autorise le multi-store. A contrario, Redux est mono-store.

Créez votre propre système simplifié d'architecture Flux :

Nous allons tenter d'appliquer cette architecure en nous concentrant uniquement sur le state et les actions d'abonnement (subscribe) et de répartition (dispatch).

Information

Nous allons ainsi nous permettre, en introduisant petit à petit d'autres outils que nous verrons dans les prochains chapitres et parties, de faire évoluer notre architecture vers un modèle se rapprochant de Redux.

Pour cela, je vous propose ci-dessous de comprendre comment implémenter simplement le mécanisme lié à cette architecture :

On va créer les fichiers index.html et flux.js avec le contenu suivant pour index.html :

<!DOCTYPE html>
<html>
    <head>
        <script src="flux.js" defer></script>
    </head>
    <body>
        <div id="header">
            Aucun propriétaire configuré
        </div>
        <form id="addForm" action="#">
            <input name="firstName" />
            <button>Enregistrer</button>
        </form>
    </body>
</html>

Et le contenu suivant pour flux.js :

let state = {};

const subscribers = [];

const subscribe = (fct) => {
    return subscribers.push(fct);
};

const dispatch = (newStateValue) => {
    state = newStateValue;
    for (const fct of subscribers) {
        fct(state);
    }
};

document.getElementById("addForm").addEventListener("submit", (evt) => {
    evt.preventDefault();
    const firstNameInput = evt.currentTarget.firstName;
    dispatch({
        owner: {
            firstName: firstNameInput.value,
        }
    })
})

subscribe((state) => {
    if (state) {
        document.getElementById("header").textContent = `Le propriétaire du restaurant est ${state.owner.firstName}`;
    }
})

Revenons ici sur l'implémentation. Nous avons créé :

  • une variable de type objet pour stocker le state;

  • une fonction d'abonnement, subscribe, qui permet d'empiler des fonctions qui seront exécutées à chaque appel à la fonction dispatch.

Ce qui nous donne dans notre fichier flux_s1.html :

<!DOCTYPE html>
<html>
    <head>
        <script src="flux.js" defer></script>
    </head>
    <body>
        <div id="header">
            Aucun propriétaire configuré
        </div>
        <form id="addForm" action="#">
            <input name="firstName" />
            <button>Enregistrer</button>
        </form>
    </body>
</html>

Et dans notre fichier flux_s1.js :

let state = {
};

const subscribers = [];

const dispatch = (newStateValue) => {
    state = newStateValue;
    for (const fct of subscribers) {
        fct(state)
    }
}

const subscribe = (subscriberFct) => {
    subscribers.push(subscriberFct);
}

subscribe((state) => {
    if (state.owner) {
        console.log('Le propriétaire est ajouté', state.owner)
        document.getElementById('header').textContent = `Le propriétaire du restaurant est ${state.owner.firstName}`
    }
})

dispatch({
    company: {
        name: 'Burger du Pré'
    }
})

document.getElementById('addForm').addEventListener("submit", (evt) => {
    evt.preventDefault()
    const firstNameInput = evt.currentTarget.firstName
    dispatch({
        company: {
            name: 'Burger du Pré'
        },
        owner: {
            firstName: firstNameInput.value,
        }
    })
})

À vous de jouer !

Et si on mettait tout de suite en place cette architecture dans le cadre de notre projet de borne de commande ?

Pour rappeler le contexte de notre projet fil rouge, nous souhaitons créer une application pour une borne de commande de burger. On devrait donc pouvoir ajouter une liste de produits à une commande, et c'est ce que nous allons réaliser dans cette première étape.

Dans cette partie du cours, je vous laisse travailler sur une architecture qui nous permettra de stocker un panier de burgers sélectionnés pour la commande.

L'objectif est d'afficher en console, de votre navigateur, une liste actualisée de burgers sélectionnés à chaque nouvelle sélection.

Information

Ci-dessous, vous trouverez des éléments qui vous aideront à concrétiser cet exercice. Les modèles sont les objets à utiliser et à insérer dans notre liste.

D'abord, un fichier flux_s2.js :

const DoubleCantal = {
    title: 'Double Cantal',
    price: 15.99,
}

const SuperCremeux = {
    title: 'Super Crémeux',
    price: 14.99,
}

const PouletCroquant = {
    title: 'Poulet Croquant',
    price: 17.99,
}

Et un fichier flux_s2.html :

<!DOCTYPE html>
<html>
    <head>
    </head>
    <body>
        <div id="header">
            Aucun propriétaire configuré
        </div>
        <form id="addForm" action="#">
            <input name="firstName" />
            <button>Enregistrer</button>
        </form>
        <div>
            <button class="orderButton" data-id="PouletCroquant">Poulet Croquant</button>
            <button class="orderButton" data-id="DoubleCantal">Double Cantal</button>
            <button class="orderButton" data-id="SuperCremeux">Super Crémeux</button>
        </div>
        <div id="command"></div>
        <script src="flux_s2.js" defer></script>
    </body>
</html>
Information

Pour vous aiguiller, vous devez adapter le dispatch pour gérer un tableau comme valeur de value dans le state, qui sera augmenté d'un nouveau burger à chaque utilisation du dispatch.

Vous avez tout implémenté ? Voici la correction :

Donc, premièrement, voici le contenu du fichier flux_s2.html :

<!DOCTYPE html>
<html>
    <head>
    </head>
    <body>
        <div id="header">
            Aucun propriétaire configuré
        </div>
        <form id="addForm" action="#">
            <input name="firstName" />
            <button>Enregistrer</button>
        </form>
        <div>
            <button class="orderButton" data-id="PouletCroquant">Poulet Croquant</button>
            <button class="orderButton" data-id="DoubleCantal">Double Cantal</button>
            <button class="orderButton" data-id="SuperCremeux">Super Crémeux</button>
        </div>
        <div id="command"></div>
        <script src="flux_s2.js" defer></script>
    </body>
</html>

Et dans flux_s2.js :

let state = {
    list: [],
};

const subscribers = [];

const subscribe = (fct) => {
    return subscribers.push(fct);
};

const dispatch = (newStateValue) => {
    state = newStateValue;
    for (const fct of subscribers) {
        fct(state);
    }
};

document.getElementById("addForm").addEventListener("submit", (evt) => {
    evt.preventDefault();
    const firstNameInput = evt.currentTarget.firstName;
    dispatch({
        owner: {
            firstName: firstNameInput.value,
        }
    })
});

subscribe((state) => {
    if (state.owner) {
        document.getElementById("header").textContent = `Le propriétaire du restaurant est ${state.owner.firstName}`;
    }
    if (state.list) {
        document.getElementById("command").innerHTML = `<h2>Vous avez sélectionné les produits suivants :</h2>;

        for (let item of state.list) {
            const itemElement = document.createElement("div");
            itemElement.innerHTML = `
                <div>
                    ${item.title} <span>${item.price}</span>
                </div>
            `;
            document.getElementById("command").appendChild(itemElement);
        }
    }
});

const DoubleCantal = {
    title: 'Double Cantal',
    price: 15.99,
};

const SuperCremeux = {
    title: 'Super Crémeux',
    price: 14.99,
};

const PouletCroquant = {
    title: 'Poulet Croquant',
    price: 17.99,
};

const PRODUCT_LIST = {
    PouletCroquant,
    SuperCremeux,
    DoubleCantal,
};

document.querySelectorAll('.orderButton').forEach((element) => {
    element.addEventListener('click', event => {
        const productId = event.target.dataset['id'];
        const productList = state.list;
        productList.push(PRODUCT_LIST[productId]);
        dispatch({
            list: productList,
        });
    });
});

Modifiez les valeurs du state de la bonne manière avec les Reducers :

Rappelez-vous l'immutabilité :

Maintenant que nous avons mis en place une méthode qui permet à la fois de centraliser un state et de communiquer les chanegements de celui-ci, nous allons nous concentrer sur la manière dont nous modifions sa valeur.

Si nous nous souvenons du fonctionnement de JavaScript au niveau de l'assignation, c'est-à-dire l'association entre les variables et les valeurs, hormis les cas des primitives ("string", "number", "boolean"...), toutes les autres valeurs sont assignées par référence : c'est l'adresse mémoire où se trouve la valeur qui est stockée. La variable ne stocke pas directement la valeur !

const A = {name: 'joe'}
const B = {name: 'joe'}

A === B
// false

Et ceci parce que les valeurs de A et B n'ont pas la même référence. C'est-à-dire, elles ne sont pas placées au même endroit de la mémoire de votre ordinateur.

Comme nous l'avons vu précédemment, nous pouvons faire des copies par valeur pour nous assurer de l'immutabilité. C'est-à-dire que nous copions tout le contenu d'une variable et nous créons une nouvelle référence.

Par contre, si nous faisons :

const C = A
C.name = "claude"

A.name
// claude

A et C partagent la même référence.

D'accord, il y a les valeurs et les références, mais pourquoi s'arrêter là-dessus ici ? Eh bien, si nous revenons sur notre implémentation précédente, nous mettons à jour la valeur de notre variable state à chaque dispatch. Ceci peut présenter des effets non désirés et des incohérences dans les données. Selon le contexte dans lequel nous faisons appel à une variable, celle-ci ne retourne pas exactement le résultat attendu.

Voici comment ces effets peuvent avoir lieu :

// flux_s3.js

function autoAddPromo() {
    setTimeout(function() {
        const newState = {...state};
        newState.list = state.list.map(item => ({...item}));
        newState.list.find((product) => product.title === "Super Crémeux").price = 2;
        dispatch(newState);
    }, 9000)
}

autoAddPromo();

Pour revenir à ce que nous venons de voir, copier une référence de state peut s'avérer contre-intuitif.

Dans notre exemple, nous avons appliqué une promotion aux produits du panier du type Super Crémeux, sauf que nous avons modifié la valeur price de l'objet en référence SuperCremeux. Donc à chaque nouvel ajout du produit SuperCremeux, celui-ci n'est plus au prix de base mais au prix promotionnel.

Pour éviter cette situation, nous allons créer une copie complète de notre state, et nous assurer qu'à chaque changement, ce soit la nouvelle copie modifiée qui soit transmise dans le dispatch.

Maîtrisez les changements avec les Reducers :

Il faudra donc à chaque fois reconstruire un nouveau state ? Oui, mais nous allons le faire de la bonne façon. Pour nous rapprocher de Redux nous allons simplifier notre méthode en introduisant un nouvel outil : le reducer.

Un reducer est une fonction qui prend le state qui prend le state courant et les données que nous souhaitons modifier et retourne le nouveau state.

const reducer = (currentState, dataToUpdate) => {
    const newState = {...currentState, ...dataToUpdate}
    return newState
}

Plutôt simple comme implémentation ! Mais pas vraiment.

Si maintenant nos fonctionnalités se complètent avec la possibilité d'appliquer une promotion sur chaque produit SuperCremeux et que l'on souhaite retirer un produit déjà sélectionné de la commande... On comprend vite que notre reducer risque de devenir compliqué !

C'est pourquoi les reducers avec Redux ont une impélmentation un peu différente. Au lieu de passer uniquement des données à mettre à jour en paramètre, nous allons passer un objet contenat :

  • une instruction de ce que nous voulons effectuer;

  • les données à modifier.

Cet objet, c'est ce qu'on appelle une action :

{
    type: 'APPLY_VOUCHER',
    payload: {
        price: 2,
    }
}

Maintenant, je vais vous montrer comment l'utiliser.

On va ajouter le bouton dans notre fichier HTML comme ceci :

<div>
    <button id="voucher">Super Crémeux à 2 euros</button>
</div>

Et dans le fichier JS, on rajoute notre reducer comme cela :

const reducter = (currentState, action) => {
    switch(action.type) {
        case 'ADD_PRODUCT':
            const listWithNewProduct = [...currentState.list, action.payload]
            return {...currentState, list: listWithNewProduct}
        case 'APPLY_VOUCHER':
            const withVoucherList = currentState.list.map(
                item => item.title === 'Super Crémeux' ? ({...item, price: action.payload.price}) : ({...item})
            )
            return {...currentState, list: withVoucherList}
        default:
            return currentState
    }
}

Et dans ce fichier, on va également modifier le code des boutons "order" :

document.querySelectorAll(".orderButton").forEach((element) => {
    element.addEventListener("click", (event) => {
        const productId = event.target.dataset["id"];
        const newState = reducer(state, {
            type: "ADD_PRODUCT",
            payload: PRODUCT_LIST[productId],
        });
        dispatch(newState);
    });
});

document.getElementById("voucher").addEventListener("click", () => {
    const newState = reducer(state, {
        type: "APPLY_VOUCHER",
        payload: { price: 2.0 },
    });
    dispatch(newState);
});

Pour résumer le code ci-dessus, ça présente comment appliquer une action dans notre reducer en veillant à bien recréer un nouveau state à chaque changement :

// Nous créons le reducer
const reducer = (currentState, action ) => {
    switch (action.type) {
        case 'ADD_PRODUCT':
            const listWithNewProduct = [...currentState.list, action.payload]
            return {...currentState, list: listWithNewProduct}
        case 'REMOVE_PRODUCT':
            const list = currentState.list.filter(
                (item, index) => index !== action.payload
            )
            return {...currentState, list: list}
        case 'APPLY_VOUCHER':
            const withVoucherList = currentState.list.map(
                        item => item.title === 'Super Crémeux' ? ({...item, price: action.payload.price}) : item
            )
            return {...currentState, list: withVoucherList}
        case 'UPDATE_FIRSTNAME':
            const owner = {...currentState.owner, firstName: action.payload}
            return {...currentState, owner}
        default:
            return currentState
    }
}

// Et nous modifions en conséquence...
dispatch({...state, company: {name: 'Burger du Pré'}})
document.getElementById('addForm').addEventListener("submit", (evt) => {
    evt.preventDefault()
    const firstNameInput = evt.currentTarget.firstName
    const newState = reducer(state, {type: 'UPDATE_FIRSTNAME', payload: firstNameInput.value })
    dispatch(newState)
})

document.querySelectorAll('.orderButton').forEach((element) => {
    element.addEventListener('click', (event) => {
        const productId = event.target.dataset['id']
        const newState = reducer(state, {type: 'ADD_PRODUCT', payload: PRODUCT_LIST[productId] })
        dispatch(newState)
    })
})

document.getElementById('voucher').addEventListener("click", (evt) => {
    const newState = reducer(state, {type: 'APPLY_VOUCHER', payload: {price: 2.00} })
    dispatch(newState)
})

Simplifiez votre architecture avec Redux :

Intallez Redux :

En tant que développeurs, nous devons nous efforcer à ne pas réinventer la roue à chaque nouveau projet.

De ce fait, il existe des outils de la communauté qui nous permettent dans la plupart des cas de répondre à nos problématiques. Et c'est ici qu'entre en jeu Redux, notre gestionnaire de state centralisé.

Tout d'abord, je vous propose d'installer Redux. Et pour ce faire, je vais vous demander d'installer Redux Toolkit.

Mais pourquoi installer Redux Toolkit alors que l'on travaille sur Redux ? Eh bien la raison est la suivante : Redux est un outil open source, propulsé par une communauté. De ce fait, les développeurs qui la composent ont dicté des "best pratices" qui font office de normes. Les pratiques actuelles préconisent d'utiliser Redux avec Redux Toolkit (RTK). C'est donc pourquoi nous apprenons à utiliser Redux avec RTK.

Installons maintenant Redux et RTK :

Ajoutez la ressource suivante dans votre fichier HTML :

<script src="https://unpkg.com/@reduxjs/toolkit@1.9.7/dist/redux-toolkit.umd.js"></script>

Dans votre navigateur, dans la console, tapez window.RTK puis Entrée. Vous devriez retrouver ce qui suit :

Redux et RTK sont installés.

Voilà Redux et RTK bien installés !

Créez un store avec Redux :

Allons-nous remplacer notre implémentation par Redux ? Oui, mais vous verrez, son utilisation ne diffère pas beaucoup de ce que nous avons réalisé.

Remplaçons notre architecture Flux par Redux dans le fichier JS :

let state = {
    list: [],
};

// Nous créons le reducer
const reducer = (currentState, action ) => {
    switch (action.type) {
        case 'ADD_PRODUCT':
            const listWithNewProduct = [...currentState.list, action.payload]
            return {...currentState, list: listWithNewProduct}
        case 'REMOVE_PRODUCT':
            const list = currentState.list.filter(
                (item, index) => index !== action.payload
            )
            return {...currentState, list: list}
        case 'APPLY_VOUCHER':
            const withVoucherList = currentState.list.map(
                        item => item.title === 'Super Crémeux' ? ({...item, price: action.payload.price}) : item
            )
            return {...currentState, list: withVoucherList}
        case 'UPDATE_FIRSTNAME':
            const owner = {...currentState.owner, firstName: action.payload}
            return {...currentState, owner}
        default:
            return currentState
    }
}

const store = window.RTK.configureStore({
    preloadedState: state,
    reducer: reducer,
});

store.subscribe(() => {
    const state = store.getState();
    if (state.owner) {
        document.getElementById("header").textContent = `Le propriétaire du restaurant est ${state.owner.firstName}`;
    }
    if (state.list) {
        document.getElementById("command").innerHTML = `<h2>Vous avez sélectionné les produits suivants :</h2>`;

        for (let i in state.list) {
            const itemElement = document.createElement("div");
            itemElement.innerHTML = `
                <div>
                    ${item.title} <span>{item.price}</span>
                    <button id="removeButton_${i}">remove</button>
                </div>
            `;
            document.getElementById("removeButton_${i}").addEventListener("click", () => {});
        }
    }
});

const DoubleCantal = {
    title: 'Double Cantal',
    price: 15.99,
};

const SuperCremeux = {
    title: 'Super Crémeux',
    price: 14.99,
};

const PouletCroquant = {
    title: 'Poulet Croquant',
    price: 17.99,
};

const PRODUCT_LIST = {
    PouletCroquant,
    SuperCremeux,
    DoubleCantal,
};

document.querySelectorAll('.orderButton').forEach((element) => {
    element.addEventListener('click', event => {
        const productId = event.target.dataset['id'];
        store.dispatch({
            type: 'ADD_PRODUCT',
            payload: PRODUCT_LIST[productId],
        });
    });
});

document.getElementById('voucher').addEventListener("click", () => {
    store.dispatch({
        type: 'APPLY_VOUCHER',
        payload: {price: 2.00},
    });
});

Pour résumer, nous avons donc créer un store avec la focntion configureStore de RTK. Ce store nous expose deux fonctions, subscribe et dispatch, ce qui nous permet de remplacer les deux fonctions implémenées précédemment. C'est tout ce qu'il y avait à faire le fonctionnement reste le même.

Information

Notez que nous avons initialisé un state via la propriété preloadedState. Cette propriété nous permet de configurer un state par défaut dans notre store.

Ce qui nous donne dans un fichier flux_s6.html :

<!DOCTYPE html>
<html>
    <head>
    </head>
    <body>
        <div id="header">
            Aucun propriétaire configuré
        </div>
        <form id="addForm" action="#">
            <input name="firstName" />
            <button>Enregistrer</button>
        </form>
        <div>
            <button class="orderButton" data-id="PouletCroquant">Poulet Croquant</button>
            <button class="orderButton" data-id="DoubleCantal">Double Cantal</button>
            <button class="orderButton" data-id="SuperCremeux">Super Cremeux</button>
        </div>
        <div>
            <button id="voucher">Super Crémeux à 2 euros</button>
        </div>
        <div id="command"></div>
        <script src="https://unpkg.com/@reduxjs/toolkit/dist/redux-toolkit.umd.min.js"></script>
        <script src="flux_s6.js"></script>
    </body>
</html>

Et dans un fichier flux6.js :

const DoubleCantal = {
    title: 'Double Cantal',
    price: 15.99,
}


const SuperCremeux = {
    title: 'Super Crémeux',
    price: 14.99,
}

const PouletCroquant = {
    title: 'Poulet Croquant',
    price: 17.99,
}

const PRODUCT_LIST = {
    PouletCroquant,
    SuperCremeux,
    DoubleCantal,
}

let state = {
    value: null,
    list: []
};
const subscribers = [];

const reducer = (currentState, action ) => {
    switch (action.type) {
        case 'ADD_PRODUCT':
            const listWithNewProduct = [...currentState.list, action.payload]
            return {...currentState, list: listWithNewProduct}
        case 'REMOVE_PRODUCT':
            const list = currentState.list.filter(
                (item, index) => index !== action.payload
            )
            return {...currentState, list: list}
        case 'APPLY_VOUCHER':
            const withVoucherList = currentState.list.map(
                item => item.title === 'Super Crémeux' ? ({...item, price: action.payload.price}) : item
            )
            return {...currentState, list: withVoucherList}

        case 'UPDATE_FIRSTNAME':
            const owner = {...currentState.owner, firstName: action.payload}
            return {...currentState, owner}
        default:
            return currentState
    }
}

const store = window.RTK.configureStore(
    {
        preloadedState: state,
        reducer
    }
)

store.subscribe(() => {
    const state = store.getState()
    if (state.owner) {
        console.log('Le propriétaire est ajouté', state.owner)
         document.getElementById('header').textContent = `Le propriétaire du restaurant est ${state.owner.firstName}`
    }
    if (state.list) {
        document.getElementById('command').innerHTML = ``;
        for (let item of state.list) {
            const itemElement = document.createElement('div')
            itemElement.innerHTML = `
                <div>
                    ${item.title} <span>${item.price}</span>
                </div>
            `
            document.getElementById('command').appendChild(itemElement)
        }
    }
})

document.getElementById('addForm').addEventListener("submit", (evt) => {
    evt.preventDefault()
    const firstNameInput = evt.currentTarget.firstName
    store.dispatch({type: 'UPDATE_FIRSTNAME', payload: firstNameInput.value })
})

document.querySelectorAll('.orderButton').forEach((element) => {
    element.addEventListener('click', (event) => {
        const productId = event.target.dataset['id']
        store.dispatch({type: 'ADD_PRODUCT', payload: PRODUCT_LIST[productId] })
    })
})

document.getElementById('voucher').addEventListener("click", (evt) => {
    store.dispatch({type: 'APPLY_VOUCHER', payload: {price: 2.00} })
})

Ajoutez Redux à une application React :

Créez une application React :

Redux fonctionne indépendamment de React. Nous avons découvert précédemment les concepts qui fondent son fonctionnement. Nous savons ainsi que nous pouvons :

  • stocker dans un store centralisé des états;

  • exécuter des parties de notre code en fonction des chanegements intervenant dans le store;

  • modifier les valeurs du store en appliquant des actions.

Créons alors notre application React en suivant les commandes ci-dessous :

## create react project
npm init react-app resto-cmd
# ou
yarn react-app resto-cmd
# ou encore
yarn create-react-app resto-cmd
Information

Vous pouvez retrouver d'autres outils qui permettent de configurer un projet React, d'ailleurs la nouvelle documentation ne mentionne plus create-react-app. L'outil reste utilisable pour configurer rapidement un projet. Vous pouvez ainsi utiliser Vite comme suit :

npm create vite@latest resto-cmd -- --template react
cd resto-cmd
npm install
npm run dev

Nous allons ajouter notre package Redux Toolkit pour bénéficier de Redux dans notre application React et du package react-redux, qui va nous permettre de manipuler le store dans nos composants :

Pour ce faire, utilisez ma commande suivante :

## add rtk as package
npm install @reduxjs/tookit react-redux
# ou
yarn add @reduxjs/tookit react-redux

À ce stade, vous devriez obtenir une application React prête à être développée avec notre state manager Redux.

Afin de préparer la suite de nos avancées, nous allons utiliser une partie du code que nous avons déjà implémenté !

Afin de suivre les bonnes pratiques, nous allons respecter certaines règles proposées dans le guide de styles que vous retrouvez dans la documentation de Redux (en anglais).

  1. D'abord, créons un dossier app/.

  2. Nous y dépaçons nos fichiers App.js et App.css en modifiant leurs imports.

  3. Ensuite, nous créons notre fichier app/store.js.

  4. Créons un dossier common pour y placer notre fichier models.js, car il sera utile à plusieurs parties de notre application et tous les composants réutilisables. Nous ajoutons nos produits dans ce fichier.

  5. Puis, en copiant l'intégralité de notre fichier flux_s6.js, nous ajoutons ce code dans le fichier store.js. J'en profite pour rajouter deux produits par défaut dans mon store. Vous pouvez voir dans le code qui suit comment je m'y prends.

  6. Nous modifions, notre import du composant App dans index.js qui devient ./app/App.js.

  7. Nous ajoutons le provider dans notre fichier app/App.js comme suit, et nettoyons un peu son implémentation :

    import { Provider } from 'react-redux';
    import { store } from './store';
    import './App.css';
    
    function App() {
        return (
            <Provider store={store}>
                <div className="App">
                </div>
            </Provider>
        );
    }
    
    export default App;

Vous devriez à ce stade avoir :

  1. Un fichier app/store.js comme suit :

    import { configureStore } from "@reduxjs/toolkit";
    import { PouletCroquant, SuperCremeux } from '../common/models";
    
    let state = {
        value: null,
        list: [
            SuperCremeux,
            PouletCroquant,
        ],
    };
    
    const reducer = (currentState, action) => {
        switch (action.type) {
            case 'ADD_PRODUCT':
                const listWithNewProduct = [...currentState.list, action.payload];
                return {...currentState, list; listWithNewProduct};
            case 'REMOVE_PRODUCT':
                const list = currentState.list.filter(
                    (item, index) => index !== action.payload
                );
                return {...currentState, list: list};
            case 'APPLY_VOUCHER':
                const withVoucherList = currentState.list.map(
                    item => item.title === 'Super Crémeux' ? ({...item, price: action.payload.price}) : item
                );
                return {...currentState, list: withVoucherList};
            case 'UPDATE_FIRSTNAME':
                const owner = {...currentState.owner, firstName: action.payload};
                return {...currentState, owner};
            default:
                return currentState;
        }
    };
    
    export const store = configureStore({
        preloadedState: state,
        reducer,
    });
  2. Un fichier common/models.js :

    export const DoubleCantal = {
        title: 'Double Cantal',
        price: 15.99,
    };
    
    export const SuperCremeux = {
        title: 'Super Crémeux',
        price: 14.99,
    };
    
    export const PouletCroquant {
        title: 'Poulet Croquant',
        price: 17.99,
    };
  3. Un fichier app/App.js :

    import { Provider } from 'react-redux';
    import { store } from './store';
    import './App.css';
    
    function App() {
        return (
            <Provider store={store}>
                <div className="App">
                </div>
            </Provider>
        );
    }
    
    export default App;
Information

Enfin, pour avoir le même rendu que moi, vous pouvez ajouter un fichier index.css.

Nous venons d'intégrer nos sources à notre projet React. Ainsi, nous avons :

  • construit un fichier store.js pour y placer notre reducer et notre configurtion du store;

  • appliqué à notre provider à la racine de notre application (App.js) afin de pouvoir accéder à notre store au sein de chacun de nos composants

  • placé nos modèles dans un fichier dédié pour mieux structurer notre code;

  • ajouté le style de l'application pour utiliser les classes toutes prêtes.

Et voilà, nous sommes enfin prêts à manipuler notre state dans React avec Redux et Redux Toolkit !

Associez le store à un premier composant :

Maintenant que notre store est configuré, notre prochain défi sera de...

... l'associer à lun de nos composants ? Exact !

Pour accéder au store de Redux dans nos composants, nous pouvons utiliser le hook useStore de react-redux que nous avons précédemment installé.

Ci-dessous, un exemple d'implémentation permettant d'accéder au store.

import { useStore } from "react-redux";

const Component = () => {
    const store = useStore();
    return <i></i>
};

useStore nous permet donc d'accéder à l'instance du store qui est diffusée dans notre application via le Provider préalablement inséré.

Maintenant que nous savons comment accéder au store, nous allons commencer à l'utiliser dans notre application.

Pour cela, nous allons créer notre composant affichant la liste des produits sélectionnés, que nous appellerons Cart, dans le fichier features/cart/Cart.js.

L'instance store fournit une méthode getState() qui nous permet d'obtenir à chaque instance les valeurs stockées dans le store. Si nous faisons donc appel à store.getState(), nous aurons accès à list qui contient notre liste de produits sélectionnés.

Il ne nous reste donc plus qu'à "mapper" sur store.getState().list en faisant :

store.getState().list.map((item, index) => <JSXElement />)

Je vous propose maintenant de comprendre comment le créer ci-dessous.

On va créer le fichier src/app/features/cart/Cart.js avec le contenu suivant :

import { useStore } from "react-redux";
    
export const Cart = () => {
    const store = useStore();

    return (
        <div className="Selection">
            <h1>Vos produits sélectionnés</h1>
            {
                store.getState().list.map(
                    (item, index) => <span key={index} className="SelectedProduct">{item.title} {item.price}</span>
                );
            }
        </div>
    );
}

N'oublions pas de rajouter ce composant dans notre application, App :

import { Provider } from 'react-redux';
import { store } from './store';
import './App.css';
import { Cart } from '../features/cart/Cart';

function App() {
    return (
        <Provider store={store}>
            <div className="App">
                <Cart />
            </div>
        </Provider>
    );
}

export default App;

Nous venons donc d'ajouter l'affichage du listing de nos produits sélectionnés dans notre panier.

Faisons le point sur cette phase d'implémentation, au cours de laquelle nous avons :

  1. Déclaré et inséré notre composant Cart dans App.js.

  2. Créé notre composant Cart dans Cart.js.

  3. Importé useStore de react-redux.

  4. Utilisé useStore pour l'assigner à notre variable locale store.

  5. Utilisé la méthode getState pour récupérer l'état (le state) courant.

  6. Mappé sur list du store qui contient la liste des produits sélectionnés.

Faites des modifications du store à partir de votre composant :

Nous voilà capables d'afficher la liste de produits sélectionnés... enfin, presque ! La liste contient des produits insérés manuellement avec le code.

Mais nous souhaitons rendre cette liste dynamique, c'est-à-dire être capables d'ajouter d'autres produits sans pour cela réécrire de code pour en ajouter.

Pour ajouter cette fonctionnalité, nous allons créer un bouton pour chaque produit et utiliser le store pour stocker le produit correspondant à chaque bouton lors du clic sur le bouton.

À l'instar de la méthode getState, l'instance store fournit une méthode dispatch qui va nous permettre d'appliquer des actions, dispatch().

Bien évidemment, la méthode dispatch fonctionne de pair avec la méthode subscribe. On va donc modifier notre accès à list et ajouter un état local qui sera modifié à chaque dispatch.

On ajoute donc :

const [list, setList] = useState(store.getState().list);

Et afin de venir modifier notre state à chaque changement du store, on ajoute à notre composant :

useEffect(() => {
    store.subscribe(() => { setList(store.getState().list) });
}, [store]);

Pour ajouter un produit, nous allons créer un bouton et ajouter un dispatch de type ADD_PRODUCT avec comme payload le modèle 'SuperCremeux' au clic sur celui-ci :

<div className="cartNavBar">
    <button onClick={() => store.dispatch({type: 'ADD_PRODUCT', payload: SuperCremeux})}>Ajouter un super crémeux</button>
</div>

On va modifier le fichier features/cart/Cart.js pour rajouter le code ci-dessus comme ceci :

import { useStore } from "react-redux";
import { SuperCremeux } from "./models";
import { useEffect, useState } from "react";

export const Cart = () => {
    const store = useStore();
    const [list, setList] = useState(store.getState().list);

    useEffect(() => {
        store.subscribe(() => setList(store.getState().list));
    });

    return (
        <div className="Selection">
            <h1>Choisir son menu</h1>
            <div className="CartNavBar">
                <button onClick={() => store.dispatch({type: 'ADD_PRODUCT', payload: SuperCremeux})}>Ajouter un super crémeux</button>
            </div>
            {
                list.map((item, index) =>, <span key={index} className="SelectedProduct">{item.title} {item.price}€</span>)
            }
        </div>
    );
};

Revoyons ensemble ce que nous venons de réaliser :

  1. Nous avons connecté notre composant au store pour écouter chaque changement de valeur de celui-ci.

  2. Nous avons exécuté une action d'ajout de produit pour modifier la valeur du store.

Débuggez votre configuration Redux :

Avant d'aller plus loin, je vous propose de vous mettre dans les meilleures conditions pour travailler avec Redux et Redux Toolkit.

En tant qu'utilisateur régulier de React, vous avez sans doute eu l'opportunité d'installer les DevTools, ces outils qui permettent de visualiser et de débugger votre application React dans la barre du navigateur.

Eh bien Redux fournit ce même genre d'outil, Redux DevTools !

Redux DevTools

Découvrons l'outil Redux DevTools :

Dans l'inspecteur du navigateur, dans l'onglet "Redux", on peut contrôler les actions qui ont eu lieu, ainsi que de les rejouter, consulter la valeur du state à un instant "T" et exécuter manuellement des actions.

Nous avons vu que cet outil nous sera utile pour débugegr notre application Redux, notamment grâce à la possibilité de suivre les changements du state, de visualiser les actions et leur contenu, la possibilité de rejouer, d'éditer des actions et encore plein d'autres fonctionnalités.

Vous savez à présent comment l'installer et l'utiliser. Il vous sera très utile pour déboguer votre application.

Partagez un state entre plusieurs composants :

Créez un second composant :

Maintenant que nous pouvons modifier notre store et consulter son état à chaque fois que c'est nécessaire, allons apprendre à le gérer à travers plusieurs composants. Ça tombe bien, c'est notre objectif principal !

Pour cela, rien de plus simple. Chaque composant de notre application peut avoir accès au store. Nous allons créer un composant dédié au motant total de la commande.

Nous rajoutons un abonnement au chanegement de valeur du store pour nous assurer que notre composant sera bien à jour à l'ajout ou à la suppression de produit dans la liste des produits sélectionnés.

Nous créons donc notre composant Total.js dans le dossier features/total/ et nous l'ajoutons à notre composant app/App.js comme suit :

import { useStore } from "react-redux";
import { useEffect, useState } from "react";

export const Total = () => {
    const store = useStore();
    const [list, setList] = useState(store.getState().list);

    const totalCommand = list.reduce((prv, cur) => cur.price + prv, 0);
    useEffect(() => {
        store.subscribe(() => setList(store.getState().list));
    });

    return <div className="TotalCommand">
        {list.length === 0 ? <div>Aucun produit sélectionné</div> : <div>Total commande {totalCommand} euros</div>}
    </div>
};

Vous vous dites que c'est plutôt simple, et c'est effectivement le cas.

Nous sommes capables de diffuser les valeurs de notre store à plusieurs composants.

Information

Si l'on se penche sur la qualité du code, on s'aperçoit que certaines répétitions sont faites entre chaque composant. Un peu de patience, nous allons bientôt nous doter d'outils pour y remédier !

Échangez des valeurs du state grâce au store :

Et si on rajoutait un composant supplémentaire pour appliquer notre bon promo ?

Pour cela, nous créons un composant Voucher, que nous allons ajouter à notre composant app/App.js et auquel nous donnerons accès à notre store. Le store nous fournit la méthode dispatch() qui va nous servir à appliquer des actions.

Si on ajoute une action de type APPLY_VOUCHER, en passant en payload la valeur {price: 2}, nous allons pouvoir appliquer notre réduction.

Sans plus tarder, ajoutons notre nouveau composant Voucher :

Vous trouverez ci-dessous une implémentation dans le fichier features/voucher/Voucher.js :

import { useStore } from "react-redux";
import { useEffect, useState } from "react";

export const Voucher = () => {
    const store = useStore();
    const [list, setList] = useState(store.getState().list);

    const available = list.find(product => product.title === 'Super Crémeux');

    useEffect(() => {
        store.subscribe(() => setList(store.getState().list));
    });

    return (
        <div className="Voucher">
            {available && <button OnClick={() => store.dispatch({ type: 'APPLY_VOUCHER', payload: { price: 2 }})}>Appliquer ma promo Super crémeux à 2 euros</button>}
        </div>
    );
};

Accédez aux valeurs du store avec les sélecteurs :

Découvrez les sélecteurs :

Prenons le temps de revenir sur nos progrès et arrêtons-nous un peu sur la façon dont nous accédons aux valeurs du store.

Dans le cas de la liste des produits sélectionnés, nous répétons chaque fois que nécessaire store.getState().list.

Afin de bien respecter les principes du DRY (Don't Repeat Yourself), nous allons regrouper nos accès. Ils seront ainsi disponibles directement via des imports. Nous avons donc besoin de déplacer l'accès à la liste, avoir le total de commande et savoir si oui ou non la promotion est utilisable.

Créons un fichier app/selectors.js dans lequel nous allons mettre nos accès au store que nous appellerons à présent, vous l'avez deviné : des sélecteurs.

Nous créons donc une fonction getProductList qui prend le state en paramètre et nous retourne list, qui est la liste des produits sélectionnés.

En deuxième temps, nous ajoutons une fonction getTotalOrder qui prend le state et nous retourne le montant total de la commande.

Information

Notez que les sélecteurs peuvent se combiner. Nous pouvons ainsi appeler getProductList pour obtenir la liste des produits et appliquer notre somme.

Enfin, nous ajoutons une fonction qui nous retourne true s'il est possible d'appliquer la promotion, false dans le cas contraire, nous la nommons isVoucherAvailable :

export const getProductList = (state) => state?.list

export const getTotalOrder = (state) => getProductList(state).reduce((prv, cur) => cur.price + prv, 0)

export const isVoucherAvailable = (state) => getProductList(state).find((product) => product.title === "Super Crémeux")

Puis, nous mettons à jour les trois fichiers suivants : features/cart/Cart.js,

import { useStore } from "react-redux";
import { SuperCremeux } from "../../common/models";
import {useEffect, useState } from "react";
import { getProductList } from '../../app/selectors';

export const Cart = () => {
    const store = useStore();

    const [list, setList] = useState(getProductList(store.getState()));

    useEffect(() => {
        store.subscribe(() => setList(getProductList(store.getState())));
    });

    return (
        <div className="Selection">
            <h1>Choisir son menu</h1>
            <div className="CartNavBar">
                <button
                    onClick={() =>
                        store.dispatch({ type: "ADD_PRODUCT", payload: SuperCremeux })
                    }
                >
                    Ajouter un super crémeux
                </button>
            </div>
            {list?.map((item, index) => (
                <span key={index} className="SelectedProduct">
                    {item.title} {item.price}€
                </span>
            ))}
        </div>
    );
};

features/cart/Total.js,

import { useStore } from "react-redux";
import { useEffect, useState } from "react";
import { getProductList, getTotalOrder } from "../../app/selectors";

export const Total = () => {
    const store = useStore();
    const [list, setList] = useState(getProductList(store.getState()));

    const totalCommand = getTotalOrder(store.getState());
    useEffect(() => {
        store.subscribe(() => setList(getProductList(store.getState())));
    });

    return <div className="TotalCommand">
        {list.length === 0 ? <div>Aucun produit sélectionné</div> : <div>Total commande {totalCommand} euros</div>}
    </div>
};

et features/voucher/Voucher.js.

import { useStore } from "react-redux";
import { useEffect, useState } from "react";
import { isVoucherAvailable } from "../../app/selectors";

export const Total = () => {
    const store = useStore();
    const [available, setAvailable] = useState(isVoucherAvailable(store.getState()));

    useEffect(() => {
        store.subscribe(() => setAvailable(isVoucherAvailable(store.getState())));
    });

    return (
        <div className="Voucher">
            {available && (
                <button
                    onClick{() =>
                        store.dispatch({ type: "APPLY_VOUCHER", payload: { price: 2 } })
                    }
                >
                    Appliquer ma promo Super crémeux à 2 euros
                </button>
            )}
        </div>
    );
};

Les sélecteurs nous permettent de partager les implémentations logiques. Ce qui nous permet de rendre réutilisables nos accès au store.

Nous venons de revisiter notre implémentation en cherchant à simplifier. Mais nous répétons malgré tout les useEffect. N'aurait-on pas un outil pour aller encore plus loin dans cette simplification ? Eh si, nous voyons juste après un outil qui va nous permettre de gérer l'accès aux valeurs du store et de "re-rendre" les composants à tout changement.

Utilisez useSelector :

Ok, très bien, on avance à grands pas. Continuons à apporter des améliorations à notre code base en exploitant encore plus d'outils par react-redux.

Nous avons précédemment utilisé le hook useEffect pour attacher le state local de chacun de nos composants aux changements du store.

Peut-on simplifier cette implémentation ? La réponse est oui, et nous allons tout de suite mettre en pratique la solution : useSelector.

Ce hook nous permet de réaliser deux choses essentielles pour accéder aux valeurs à jour de notre store. Il récupère l'état complet du store. Nous pouvons donc accéder à toutes les valeurs.

De plus, il s'abandonne aux changements d'état du store. Nous pouons ainsi nous passer d'attacher un état local puisque le hook va exécuter notre sélecteur passé en paramètre à chaque changement d'état du store. Ce qui nous assure un rerender en cas de changement de valeur.

Pour l'utiliser, rien de plus simple.

Pour notre liste de produits, nous remplaçons notre constante list et le state local par une constante list à laquelle nous assignons la valeur de useSelector(getProductList) comme suit :

const list = useSelector(productList)

Notre implémentation sera beaucoup plus simple. On va remplacer nos useEffect et setState par un simple useSelector dans nos fichiers : features/cart/Cart.js,

import { useSelector, useStore } from "react-redux";
import { SuperCremeux } from "../../common/models";
import { getProductList } from '../../app/selectors';

export const Cart = () => {
    const store = useStore();

    const list = useSelector(getProductList);

    return (
        <div className="Selection">
            <h1>Choisir son menu</h1>
            <div className="CartNavBar">
                <button onClick={() => store.dispatch({ type: 'ADD_PRODUCT', payload: SuperCremeux })}>Ajouter un super crémeux</button>
            </div>
            {
                list.map(
                    (item, index) => <span key={index} className="SelectedProduct">{item.title} {item.price}€</span>
                )
            }
        </div>
    );
};

features/voucher/Voucher.js,

import { useSelector, useStore } from "react-redux";
import { isVoucherAvailable } from "../../app/selectors";

export const Voucher = () => {
    const store = useStore();

    const available = useSelector(isVoucherAvailable);

    return <div className="Voucher">
        {available && <button onClick={() => store.dispatch({type: 'APPLY_VOUCHER', payload: { price: 2 }})}>Appliquer ma promo Super crémeux à 2 euros</button>}
    </div>
};

et features/total/Total.js.

import { useSelector } from "react-redux";
import { getProductList, getTotalOrder } from "../../app/selectors";

export const Total = () => {
    const list = useSelector(getProductList);
    const totalCommand = useSelector(getTotalOrder);

    return <div class="TotalCommand">
        {list.length === 0 ? <div>Aucun produit sélectionné</div> : <div>Total commande {totalCommand} euros</div>}
    </div>
};
Information

Petit arrêt sur notre sélecteur getTotalOrder, vous avez sans doute remarqué qu'à certaines reprises, le montant affiché à un format pas très esthétique, par exemple : Total commande 45.980000000000004 euros / Total commande 94.94999999999999 euros.

Je vous propse cette solution, mais il y en a d'autres. Dans le fichier app/selectors.js, ajoutez :

export const getTotalOrder = (state) => getProductList(state).reduce((prv, cur) => Math.round((cur.price + prv) * 100) / 100, 0)

Découvez Redux ToolKit :

Découvez les avantages de Redux Toolkit :

Enfin ! Nous l'avons déjà installé avec Redux, mais nous n'en avons pas encore parlé - c'est quoir Redux Toolkit ? Redux Toolkit est une librairie qui facilite la gestion de l'état dans les applications React avec Redux. Développée pour rendre l'utilisation de Redux plus simple, plus concise et plus agréable, en fournissant des utilisateurs prédéfinis pour certaines tâches courantes. Elle est suivie d'une documentation complète et d'un guide des meilleures pratiques afin de nous aider à intégrer Redux d'une façon plus conventionnelle.

Elle embarque aussi une bonne partie des modules complémentaires de Redux qui faciliteront le développement. Par exemple, la gestion du code asynchrone et le déclenchement automatique des actions en fonction du résultat de la promesse avec createAsyncThunk. Ou bien, de pouvoir combiner la génération d'action, de reducers et de sélecteurs et de composer notre store par fonctionnalités grâce à createSlice.

Ne vous inquiétez pas, on verra tout ça prochainement, ainsi que d'autres outils qui compléteront notre "kit".

Information

Et bonne nouvelle - si vous avez déjà une application Redux, cette librairie est conçue pour être compatible avec le code existant. Vous pouvez donc migrer progressivement sans avoir à tout réécrire.

Enfin, Redux Toolkit est soutenu par la communauté et dispose de tutoriels et exemples pour vous aider à démarrer rapidement, en plus de sa documentation solide.

Définissez vos actions avec createAction :

Voyons tout de suite comment Redux Toolkit facilite la création de nos actions !

Dans nos précédents appels au dispatch, nous avons écrit entièrement un objet de la forme suivante pour définir l'action :

{
    type: "ACTION_NAME",
    payload: {
        ...
    }
}

Pour éviter de réécrire à chaque fois nos actions à dispatch, nous pouvons définir ce qu'on appelle des action creators. C'est-à-dire des fonctions qui vont nous retourner un objet, dont les propriétés correspondent à celles de l'action, et l'appeler comme ci-dessous :

const myAction = (payloadValue) => ({
    type: "ACTION_NAME",
    payload: payloadValue,
})
store.dispatch(myAction({/* payload */}))

Et si nous voulons utiliser la même clé d'action pour notre reducer, nous devons exporter la clé comme ci-dessous :

export const MY_ACTION = "MY_ACTION"

const myAction = (payloadValue) => ({
    type: MY_ACTION,
    payload: payloadValue,
})
import { MY_ACTION } from "./actions";

const reducer = (state = initialState, action) => {
    switch (action.type) {
        case MY_ACTION:
            return {
                ...state,
                /* payload */
            }
        default:
            return state
    }
}

Redux Toolkit nous fournit un outil pour simplifier cela, createAction.

createAction renvoie un action creator, et la variable créée peut être utilisée comme clé dans nos reducers.

On voit comment l'utiliser ci-dessous :

import { createAction } from '@reduxjs/toolkit'

export const myAction = createAction('MY_ACTION')

store.dispatch(myAction(/* payload */))

Notez que createAction peut prendre un deuxième paramètre qui permet de préparer le payload associé à l'action. C'est très utile lorsque nous voulons nous assurer du format des données, par exemple. Ci-dessous, une petite démonstration :

export const addEvent = createEvent('ADD_EVENT', (date) => ({
    payload: {
        day: date.getDate(),
        month: date.getMonth(),
        year: date: date.getFullYear(),
    }
}))

store.dispatch(addEvent(new Date("2023-03-03")))

Via l'exemple ci-dessus, nous nous assurons que ce qui est passé en payload n'est pas une date JavaScript, mais directement un objet contenant explicitement le jour, le mois et l'année.

Définissez vos reducers avec createReducer :

Nous venons de voir createAction, voilà qui simplifie grandement la gestion des actions.

Mais quid de celle-ci dans nos reducers ? Aurions-nous une manière plus simple et plus esthétique de gérer nos actions dans nos reducers ? Eh bien oui, grâce à createReducer.

C'est une fonction qui nous permet de générer un reducer et d'y associer les différentes actions. Elle prend en paramètre l'état initial et une fonction qui va nous permettre d'ajouter chacun de nos cas. Ci-après, je vous montre comment l'utiliser dans des portions de code "démos" qui présentent la forme, et ne sont pas rattachées à notre projet fil rouge.

D'abord, en passant une fonction builder :

import { createAction, createReducer } from '@reduxjs/toolkit'

export const myAction = createAction('MY_ACTION')

const initialState = {}

export const reducer = createReducer(initialState, function(builder) {
    builder.addCase(myAction, (state, action) => {
        /** RETURN NEW STATE VALUE */
    })
})

On peut aussi bien définir nos actions en passant un objet en paramètre :

export const reducer = createReducer(initialState, {
    [myAction]: (state, action) => ({
        /** RETURN NEW STATE VALUE */
    })
})

On peut donc très bien passer un objet pour configurer le reducer ou bien passer une fonction builder, d'où les deux implémentations précédentes.

Mettez en place des slices :

Découvrez les slices :

Notre application évolue à grands pas ! Nous savons à présent organiser notre store autour des actions et de notre reducer.

Comment organiser notre store pour éviter d'avoir tout dans le même fichier ? Peut-on découper celui-ci ? Eh bien, c'est ce que nous allons voir maintenant avec le principe des slides (en français, tranches).

Reux Toolkit fournit et encourage l'utilisation de slices d'état, ce qui permet d'organiser l'état en morceaux autonomes et gérables, facilitant la création et la maintenance de l'application.

Notre store peut être composé de ces slices et nous allons pouvoir les associer à nos fonctionnalités en les ajoutant au niveau des dossiers features.

Ainsi, nous allons avoir une structure comme ci-dessous par feature :

features/
    my-feature/
        MyFeature.js
        myFeatureSlice.js

Voyons tout de suite comment créer nos slices et ce que Redux Toolkit nous apporte.

Organisez le store avec createSlice :

Redux Toolkit nous permet donc d'organiser notre store en slices. Nous utiliserons createSlice pour cela. Cette fonction nous apporte plusieurs choses qui vont simplifier l'implémentation de notre store.

Par exemple, précédemment, nous devions utiliser createAction et associer nos actions à notre reducer.

Eh bien, createSlice nous permet de nous en passer.

createSlice est une fonction de Redux Toolkit qui génère automatiquement des reducers, des actions et des action creators en se basant sur un objet définissant l'état initial et les fonctions réductrices.

Redux Toolkit fait presque le café.

Regardons comment créer notre slice ! Nous allons :

  • créer notre slice de notre fonctionnalité Cart.js qui permet de lister les produits sélectionnés;

  • déplacer les actions pour :

    • l'ajout d'un produit,

    • la suppression d'un produit,

    • l'ajout de la promo;

    import { createSlice } from '@reduxjs/toolkit';
    
    export const cartSlice = createSlice({
        name: 'list',
        initialState: {}, // le state par défaut de notre slice
        reducers: {
            // action ADD_PRODUCT déplacée ici
            addProduct: (currentState, action) => {
                const listWithNewProduct = [...currentState, action.payload];
                return listWithNewProduct;
            },
            // action REMOVE_PRODUCT déplacée ici
            removeProduct: (currentState, action) => {
                const list = [...currentState.list].filter(
                    (item, index) => index !== action.payload
                );
                return list;
            },
            // action APPLY_VOUCHER déplacée ici
            applyVoucher: (currentState, action) => {
                const withVoucherList = currentState.map(
                    item => item.title === 'Super Crémeux' ? ({...item, price: action.payload.price}) : item
                );
                return withVoucherList;
            }
        },
    });
  • modifier notre app/store.js pour combiner 2 reducers : celui créé directement par notre slice et les actions restantes pour la mise à jour du propriétaire du restaurant.

    import { combineReducers, configureStore, createAction, createReducer } from "@reduxjs/toolkit";
    import { cartSlice } from "../features/cart/cartSlice";
    
    let state = {
        owner: {},
        list: []
    };
    
    export const updateFirstName = createAction('UPDATE_FIRSTNAME', (firstName) => {
        return {
            payload: firstName
        };
    });
    
    const reducer = createReducer(state, {
        // on garde cette partie qui ne fait pas partie de notre slice
        [updateFirstName]: (currentState, action) => {
            const owner = {...currentState.owner, firstName: action.payload};
            return {...currentState, ...owner};
        }
    });
    
    export const store = configureStore(
        {
            preloadedState: state,
            reducer: combineReducers({ // utilisé pour combiner nos reducers
                owner: reducer,
                list: cartSlice.reducer,
            })
        }
    );

Configurez vos reducers avec les EntityAdapters :

Comprenez ce qu'est un EntityAdapter :

Nous faisons bien souvent des applications avec la même approche, et des fonctionnalités qui se rapprochent bien plus qu'il nous paraît...

Si nous stockons une liste de "choses", il est évident qu'une des fonctionnalités sera d'ajouter une "chose" à cette liste. De plus, si nous stockons une liste, nous souhaitons généralement de l'afficher.

Dans notre cas, imaginons qu'un client ayant des allergies alimentaires veuille pouvoir indiquer les aliments à retirer de son burger. Pour ce faire, nous pouvons ajouter la fonctionnalité d'ajout de notes qui seront affichées aux cuisiniers de notre restaurant.

Nous pourrions ajouter une slice, des sélecteurs... ou utiliser un outil qui nous permettrait de simplifier la configuration !

Vous voyez où je veux en venir ? Je parle bien du CRUD :

  • Create (créer),

  • Read (lire),

  • Update (mettre à jour),

  • Delete (supprimer).

Ce sont des opérations que nous pouvons qualifier de génériques. C'est-à-dire que ce sont des actions qui ont des comportements identiques mais qui sont appliquées à des objets différents.

Je vous mets un petit exemple dans le code suivant :

const houses = [
    {
        id: 1,
        name: 'house1',
        price: 100000
    },
    {
        id: 2,
        name: 'house2',
        price: 200000
    },
    {
        id: 3,
        name: 'house3',
        price: 300000
    }
]

const students = [
    {
        id: 1,
        name: 'student1',
        average: 20
    },
    {
        id: 2,
        name: 'student2',
        average: 19
    },
    {
        id: 3,
        name: 'student3',
        average: 18
    }
]

const getHouses = () => {
    return houses
}

const getStudents = () => {
    return students
}

const getHouseById = (id) => {
    return houses.filter(house => house.id === id)
}

const getStudentById = (id) => {
    return students.filter(student => student.id === id)
}

const addHouse = (house) => {
    houses.push(house)
}

const addStudent = (student) => {
    students.push(student)
}

Ci-dessus, students et houses sont des listes d'objets différents. À première vue, on ne peut pas avoir les mêmes actions. Mais en prenant un peu de hauteur, addStudent et addHouse font la même chose, ajouter un élément dans la liste : students pour addStudent et houses pour addHouse.

On peut à priori simplifier tout ça.

Je vous propose la petite implémentation ci-après :

const entityAdapter = () => {
    const entities = [];
    return (
        addOne: (entity) => {
            entities.push(entity);
            return this;
        },
        selectAll: () => entities,
        selectById: (id) => entities.find(entity => entity.id === id),
    );
};

const students = entityAdapter();
const houses = entityAdapter();

Nos deux constantes students et houses ont maintenant des actions associées (addOne, selectAll et selectById). Plus besoin de les créer une à une, grâce à un outil inclus dans Redux Toolkit : EntityAdapter.

Cet outil permet de configurer notre store et de générer les outils qui vont nous permettre d'effectuer simplement ces opérations CRUD.

L'EntityAdapter est une fonction qui génère un ensemble de reducers et de sélecteurs destiné à effectuer des opérations CRUD. Avec cet outil, nous aurons accès à des méthodes comme addOne pour ajouter une entrée, addMany pour ajouter plusieurs entrées, setOne pour modifier une entrée, etc.

Créez un EntityAdapter :

Pour utiliser cette fonctionnalité, utilisons createEntityAdapter de Redux Toolkit.

Cette fonction s'emploie un peu dans notre implémentation simplifiée vue dans le paragraphe juste au-dessus, avec la particularité de pouvoir surcharger chaque action du CRUD. Pour ce faire, on peut passer en paramètre un objet qui reprend chaque action devant être adaptée en cas de besoin.

Par exemple, admettons que l'id de nos élèves ne soit pas stocké dans student.id mais dans student.studentId. Notre précédente implémentation ne fonctionnerait pas pour students.

En effet, cette fonction ne retournera aucun résultat, car entity.id sera toujours undefined :

selectById: (id) => entities.find(entity => entity.id === id),

Il nous faut donc un moyen d'adapter notre selectById selon l'entité, mais uniquement pour cette fonction.

Avec createEntityAdapter, nous n'avons qu'à ajouter la fonction qui va être appliquée au moment de faire student.selectById, en passant un objet comme ceci :

{
    selectById: (students, id) => students.find(student => student.studentId === id),
}

Ce qui nous donne le code suivant :

import { createEntityAdapter } from '@reduxjs/toolkit'

const students = createEntityAdapter({
    selectById: (students, id) => students.find(student => student.studentId === id),
})

createEntityAdapter va donc générer un objet, students, avec des actions prédéfinies.

Information

Vous pouvez retrouver la liste complète des fonctions CRUD dans la documentation de Redux Toolkit.

Notre fonction selectById sera exécutée lorsque nous ferons appel à students.selectById et nous aurons bien un résultat pour un id donné.

Pour ajouter notre entityAdaptor à notre store, rien de plus simple. Comme je vous l'ai dit, il génère notre reducer et nos sélecteurs. Ci-dessous, je vous montre comment l'ajouter au store et comment extraire les sélecteurs :

const students = createEntityAdapter({
    selectById: (students, id) => students.find(student => student.studentId === id),
})

const studentSlice = createSlice({
    ...,
    reducers: {
        add: students.addOne,
    }
})

const store = configureStore({
    reducer: {
        students: studentSlice.reducer,
    },
})

const studentsSelectors = students.getSelector(state => state.students)

Mettez à jour le store de façon asynchrone :

Revenez sur l'asynchrone :

JavaScript est un langage dit "single-thread", c'est-à-dire que les tâches sont exécutées les unes après les autres sans possibilité d'exécuter deux choses ou plus à la fois.

Si vous arrivez à exécuter le script suivant dans votre navigateur, vous verrez que le temps qu'il se termine, rien d'autre ne pourra être fait :

let a = 0
const b = 10_000_000_000 // notation BIGINT
console.log(new Date())
for (let i = 0; i < b; i++) {
    a += i
}

console.log(a)
console.log(new Date())

Tous les autres scripts ou actions devront attendre la fin de celui-ci avant de s'exécuter.

Dans bien des cas, ce fonctionnement sera problématique. Par exemple, faire une requête de données pourrait bloquer la page.

Comment éviter ce comportement ? Eh bien avec JavaScript, nous pouvons contourner ce problème en utilisant la programmation asynchrone. Vous connaissez sans doute le gestionnaire d'événements, la fameuse méthode addEventListener : c'est une approche de l'asynchrone.

Quel impact cette approche a-t-elle sur notre store Redux ? Et comment l'ajouter à notre store ? Nous allons pour cela introduire un outil Redux, les middlewares, et ainsi utiliser createAsyncThunk pour gérer l'asynchrone dans notre store.

Interceptez les chnagements du state avec les middlewares :

Les middlewares sont en quelque sorte une pile de fonctions qui exécutent un traitement sur une donnée, puis la transmettent jusqu'à la fonction suivante jusqu'à ce qu'une des fonctions termine le programme ou qu'il n'y ait plus de fonction à la suite.

Information

Vous avez sans doute déjà lu ce terme quelque part : par exemple, certaines librairies serveur avec Node.js utilise cette approche. On peut citer Express ou bien Koa. Mais cette approche ne se limite pas à l'univers JavaScript, et je vous invite à vous documenter si vous découvrez les middlewares. Pour ces frameworks, on utilise les middlewares pour intercepter et on traite les requêtes HTTP avant de retourner une réponse.

Je vous fais un petit schéma ci-dessous pour représenter le processus :

Comment les middlewares fonctionnent ?

Les middlewares interceptent donc les valeurs et les modifient si nécessaire. Si on veut ajouter ou supprimer une valeur avant de laisser la main à la fonction suivante, par exemple, ou bien si on veut vérifier des données avant que l'application fasse un traitement sur ces données.

Les middlewares Redux fonctionnent de cette manière en interceptant les actions avant de les transmettre aux reducers. Il est donc possible de traiter les valeurs des payloads ou de modifier les types des actions avant que les reducers ne traitent les actions.

Cette fonctionnalité nous permet par exemple d'ajouter du logging, du reporting en cas de crash, de faire des appels à des API, et bien plus.

Je ne vais pas vous proposer de créer un middleware, mais pour mieux comprendre ses interactions avec les actions et le store, je vous suggère de me suivre ci-dessous :

On va déclarer une propriété middleware dans notre store, c'est-à-dire dans la fonction configureStore, qui est un tableau et chaque middleware est une fonction qui retourne une fonction retournant encore une fonction.

Si vous voulez essayez vous-même, voici le code que vous pouvez copier et coller dans le store :

export const store = configureStore(
    {
        preloadedState: state,
        reducer: combineReducers({
            owner: ownerSlice.reducer,
            list: cartSlice.reducer,
            notes: notesSlice.reducer,
        }),
        // Liste des middlewares
        middleware: (getDefaultMiddleware) =>
            getDefaultMiddleware().preprend([
                (store) => (next) => (action) => {
                    console.log('Action', action);
                    next(action);
                }
            ]);
    }
);

Ce middleware a un fonctionnement très simple : il reçoit l'action lorsque l'on utilise le dispatch du store, il affiche l'action dans la console, et transmet l'action au middleware suivant.

Vous devriez voir dans la console de votre navigateur s'afficher l'action exécutée, et cela, pour toutes les actions.

Utilisez les thunks pour modifier le store de façon asynchrone :

Nous savons ce que sont les middlewares et nous maîtrisons le concept d'asynchrone. Voyons maintenant comment combiner ces deux aspects afin d'exécuter des traitements asynchrones dans notre store. Cela nous permettra d'utiliser les appels de données via le réseau, avec fetch par exemple.

Pour cela, nous allons utiliser les thunks de Redux fournis par le middleware redux-thunk.

Un thunk est une fonction qui s'exécute de façon synchrone ou asynchrone et qui prend en paramètres les méthodes dispatch et getState de notre store :

Comme pour les actions, il est préférable de créer des thunks actions creators, thunkCreator, comme ci-dessous :

const thunkCreator = (arg1, arg2) => {
    return async (dispatch, getState) => {
        // on met ici la logique du thunk
    };
};

Nous allons maintenant créer notre premier thunk.

Admettons que pour toute commande de Poulet Croquant, nous voulions demander au client s'il veut bénéficier d'une offre spéciale : 3 burgers 'Poulet Croquant' achetés, le troisième à moitié prix. Et pour cela, nous lui demandons dans une boîte de dialogue s'il veut en profiter en ajouter un troisième poulet croquant 5 secondes après qu'il a ajouté 2.

Pour comprendre comment implémenter cette fonctionnalité, il faut donc modifier notre fichier features/menu/Menu.js pour changer notre dispatch d'ajout de produit.

Nous allons créer un thunk action creator et ajouter une confirmation de l'utilisateur. Celle-ci s'affiche au bout de 5 secondes après l'ajout du burger si celui-ci est de type Poulet Croquant et au nombre de 2 dans le panier :

import { useDispatch } from 'react-redux';
import * as ProductList from '../../common/models';
import { ProductCard } from '../../common/components/ProductCard';
import { cartSlice } from '../cart/cartSlice';
import { getListQuantityProductPerName } from '../../app/selectors';

const addProductThunk = (product) => (dispatch, getState) => {
    dispatch(cartSlice.actions.addProduct(product));
    return new Promise((resolve) => {
        setTimeout(() => {
            const state = getState();
            const numberProductPerName = getListQuantityProductPerName(state);
            const numberForSpecialOffer = numberProductPerName.find((item) => item.title === 'Poulet Croquant')?.quantity;
            if (numberForSpecialOffer === 2) {
                window.confirm("Voulez-vous ajouter une troisième fois ce produit à moitié prix ?");

                const specialOffer = ProductList.PouletCroquant;
                dispatch(cartSlice.actions.addProduct({...specialOffer, price; Math.round((ProductList.PouletCroquant.price / 2) * 100) / 100}));
            }
            resolve();
        }, 5000);
    });
};

export const Menu = () => {
    const dispatch = useDispatch();
    return <div className="Menu">
        {
            Object.values(ProductList).map(product => <PorductCard key={product.name} product={product} onSelect={() => dispatch(addProductThunk(product))} />);
        }
    </div>
}

Utilisez createAsyncThunk pour notre store :

Vous l'aurez sans doute deviné, Redux Toolkit nous amène un outil pour gérer nos thunks, createAsyncThunk !

Je vous propose donc réécrire notre thunk d'ajout de produit en utilisant cette fois-ci createAsyncThunk de RTK.

Dans notre fichier cartSlice.js, on reporte notre fonction addProductThunk.

Elle devient une fonction qui :

  • en premier paramètre, comme pour les actions, va prendre une clé pour identifier le thunk;

  • en deuxième paramètre, en tant que fonction asynchrone, prend toujours en dernier paramètre le thunkApi.

import { createAsyncThunk, createSlice } from "@reduxjs/toolkit";
import { getListQuantityProductPerName } from "../../app/selectors";
import * as ProductList from "../../common/models";

export const addProductThunk = createAsyncThunk('cart/addProductThunk', async (product, thunkApi) => {

});

Le thunkApi nous donne accès à plusieurs choses : le dispatch et le getState du store, une fonction rejectWithValue et une fonction fulfillWithValue pour annuler ou valider l'action asynchrone, ainsi que d'autres paramètres tels que requestId, signal et extra.

Nous simplifions notre fonction en veillant à dispatch un produit dans la liste sélectionné via thunkApi.dispatch(cartSlice.actions.addProduct(product)); :

export const addProductThunk = createAsyncThunk('cart/addProductThunk', async (product, thunkApi) => {

    // Ajout du produit au panier via le dispatch du store
    thunkApi.dispatch(cartSlice.actions.addProduct(product));
});

Selon le choix de l'utilisateur, nous retournons une Promise qui sera résolue (resolve()), s'il valide l'ajout au panier de la promo, et rejetée (reject()) dans le cas contraire.

Notre fonction addProductThunk devient donc :

export const addProductThunk = createAsyncThunk('cart/addProductThunk', async (product, thunkApi) => {

    thunkApi.dispatch(cartSlice.actions.addProduct(product));
    // Promise retourner par notre thunk
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            const state = getState();
            const numberProductPerName = getListQuantityProductPerName(state);
            const numberForSpecialOffer = numberProductPerName.find((item) => item.title === 'Poulet Croquant')?.quantity;
            if (numberForSpecialOffer === 2) {
                if (window.confirm("Voulez-vous ajouter une troisième fois ce produit à moitié prix ?")) {
                    
                    resolve();
                } else {
                    reject();
                }
            } else {
                reject();
            }
        }, 5000);
    });
});

Dans notre slice, nous ajoutons les cas d'actions qui nous intéressent via la propriété `extraReducers` :

  • builder.addCase(addProductThunk.rejected, ...) lorsque notre fonction asynchrone sera rejected

  • builder.addCase(addProductThunk.fulfilled, ...) lorsqu'elle sera resolved.

extraReducers: function(builder) {
    builder.addCase(addProductThunk.fulfilled, (state) => {
        const specialOffer : ProductList.PouletCroquant;
        return [...state, {...specialOffer, price: Math.round((ProductList.PouletCroquant.price / 2) * 100) / 100}];
    });
    builder.addCase(addProductThunk.rejected, (state) => {
        return [...state];
    });
}

Pour le premier cas, nous renvoyons le state non modifié. Pour le second, nous ajoutons un produit à moitié prix.

Retrouvez ci-dessous l'implémentation complète de ces modifications dans le fichier features/cart/cartSlice.js :

import { createAsyncThunk, createSlice } from "@reduxjs/toolkit";
import { getListQuantityProductPerName } from "../../app/selectors";
import * as ProductList from '../../common/models';

export const addProductThunk = createAsyncThunk( 'cart/addProductThunk' , async (product, thunkApi) => {
    // Ajout du produit au panier via le dispatch du store
    thunkApi.dispatch(cartSlice.actions.addProduct(product));
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            const state = thunkApi.getState();
            const numberProductPerName = getListQuantityProductPerName(state);
                const numberForSpecialOffer = numberProductPerName.find((item) => item.title === "Poulet Croquant")?.quantity;
                if (numberForSpecialOffer === 2) {
                    if (window.confirm("Voulez-vous ajouter une troisième fois ce produit à moitié prix ?")) {
                        resolve();
                    } else {
                        reject();
                    }
                } else {
                    reject();
                }
        }, 5000);
        });
});

export const cartSlice = createSlice({
    name: 'list',
    initialState: {},
    reducers: {
        addProduct: (currentState, action) => {
            const listWithNewProduct = [...currentState, action.payload];
            return listWithNewProduct;
        },
        removeProduct: (currentState, action) => {
            const list = [...currentState].filter(
                (item, index) => index !== action.payload
            );
            return list;
        },
        applyVoucher: (currentState, action) => {
            const withVoucherList = currentState.map(
                item => item.title === 'Super Crémeux' ? ({...item, price: action.payload.price}) : item
            );
            return withVoucherList;
        },
    },
    extraReducers: function(builder) {
        builder.addCase(addProductThunk.fulfilled, (state) => {
            const specialOffer = ProductList.PouletCroquant;
            return [...state, {...specialOffer, price: Math.round((ProductList.PouletCroquant.price / 2) * 100) / 100}];
        });
        builder.addCase(addProductThunk.rejected, (state) => {
            return [...state];
        });
    }
});

N'oublions pas de modifier le fichier features/menu/Menu.js et d'ajouter l'import de addProductThunk en supprimant l'implémentation précédente :

import { addProductThunk } from '../cart/cartSlice';

Utilisez des web services :

Découvrez les web services :

À partir du moment où votre application est connectée, c'est-à-dire qu'elle interagit avec un service distant, elle utilise des web services. Les web services peuvent être sous plusieurs formes : des requêtes API REST, une API GraphQL, ou des websockets. Cela peut être pour accéder à un compte utilisateur, récupérer la liste de messages actualisée d'un blog, ou consulter les notifications, par exemple.

Dès qu'elle consomme de la donnée à partir d'un autre serveur ou service, ou qu'elle y exécute une action, notre application fait appel de manière asynchrone à des web services.

Cela tombe à pic ! Nous venons de voir comment gérer les asynchrones grâce aux thunks.

Dans la suite, nous allons d'abord utiliser fetch, l'outil standard pour exécuter nos appels de données. Puis, nous utiliserons un outil de Redux Toolkit, Query, qui va nous permettre de créer des configuration d'appels API.

Utilisez fetch pour appeler une API :

Pour exécuter des appels API pour notre store, nous pouvons utiliser fetch et les thunks.

Nous allons apprendre à le faire en mettant en place une fonctionnalité de notre application qui permettra de savoir si les produits sont disponibles, et si ce n'est pas le cas, nous rendrons la commande inactive pour ceux-ci.

Vous avez sans doute déjà remarqué les vignettes "Victime de son succès" dans les fast-foods. Eh bien, c'est ce que nous allons faire !

Information

Nous avons donc besoin de faire appel à un service web qui va nous lister les produits indisponibles. Nous n'aurons malheureusement pas le temps, et ce n'est pas l'objectif de ce cours, de créer un webservice. Je vous propose donc d'utiliser un simple fichier que vous trouverez dans ce Git, qui listera les produits indisponibles.

Nous allons donc ajouter une slice features/menu/menuSlice.js, un thunk 'getUnaibleThunk' et appeler ce dernier au montage de notre composant features/menu/Menu.js.

Ce thunk va exécuter une requête GET sur notre fichier /unavailable.json avec fetch et nous insérera son contenu dans notre state après avoir sérialisé la réponse avec fetch.json().

Puis, nous allons ajouter un selector getUnavailableProducts qui nous retournera la liste des produits non disponibles. Nous ajoutons la propriété unavailable à notre composant ProductCard en mettant true si notre produit est dans la liste des produits non disponibles, false dans le cas contraire.

C'est parti pour ajouter notre slice au store !

Nous ajoutons ceci à app/selectors.js :

export const getUnavailableProducts = (state) => state?.menu?.unavailableProducts

Nous ajoutons ceci à app/store.js :

reducer: combineReducers({
    owner: ownerSlice.reducer,
    list: cartSlice.reducer,
    menu: menuSlice.reducer,
}),

Nous modifions features/menu/Menu.js :

import { useDispatch, useSelector } from 'react-redux';
import * as ProductList from '../../common/models';
import { ProductCard } from '../../common/components/ProductCard';
import { addProductThunk } from '../cart/cartSlice';
import { useEffect } from 'react';
import { getUnavailableThunk } from './menuSlice';
import { getUnavailableProducts } from '../../app/selectors';

export const Menu = () => {
    const dispatch = useDispatch();
    const unavailableProducts = useSelector(getUnavailableProducts);

    useEffect(() => {
        dispatch(getUnavailableThunk());
    }, []);

    return (
        <div className="Menu">
            {
                Object.values(ProductList).map(product => <ProductCard unavailable={unavailableProducts?.includes(product.title)} key={product.name} product={product} onSelect={() => dispatch(addProductThunk(product))} />)
            }
        </div>
    );
};

Nous ajoutons features/menu/menuSlice.js :

import { createAsyncThunk, createSlice } from "@reduxjs/toolkit";

export const getUnavailableThunk = createAsyncThunk( 'cart/getUnavailableThunk' , async (thunkApi) => {
    return await (async () => {
    const response = await fetch('https://gist.githubusercontent.com/techerjeansebastienpro/f28e00c733c8e0b3fda7718072076ff3/raw/7fd0e66c68afeea5171255396d7e04493a457e50/unavailable.json');
        return await response.json();
    })();
});

export const menuSlice = createSlice({
    name: 'menu',
    initialState: {
        unavailableProducts: []
    },
    reducers: {},
    extraReducers: (builder) => {
        builder.addCase(getUnavailableThunk.fulfilled, (state, action) => {
            return {...state, unavailableProducts: action.payload};
        });
    }
});

Simplifiez vos appels API avec Query :

Devinez quoi !

Redux Toolkit nous fournit un outil pour les appels API ? Eh oui ! Vous l'avez deviné.

Cet outil c'est Query ! Il nous permet de configurer nos calls API et de gérer plusieurs aspects à notre place, comme le cache, la gestion d'erreur, le polling; très utile quand on veut actualiser automatiquement des données à invervalle régulier.

Query, via la fonction createApi, va générer à notre place tous les outils pour notre store, comme le reducer, la définition des actions ou thunks, la définition des selectors... les briques indispensables si on veut aller vite en utilisant les webservices.

Information

Notez bien que Query est une extension de Redux Toolkit et que vous pouvez bien faire vos applications avec Redux Toolkit sans l'utiliser. Nous essayons ici de finaliser notre tour découverte du kit en visitant un outil construit au-dessus de RTK et qui est très complet.

Pour utiliser Query, on utilise la méthode createApi de @reduxjs/toolkit/query comme ce qui suit :

import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';

const api = createApi({
    baseQuery: fetchBaseQuery({ baseUrl: 'BASE_URL' }),
    endpoints: (builder) => ({
        myEndpoint: builder.query({
            query: (id) => `path/${id}`,
        }),
    }),
});

Tout plein d'outils que nous ne verrons malheureusement pas, mais que vous aurez sans doute le plaisir d'utiliser.

Ce que nous allons voir ici, c'est comment poser les bases avec Query, et pour cela, je vous propose de réaliser ce qui suit.

Nous allons ensemble mettre en place une fonctionnalité qui va permettre à l'utilisateur de récupérer son solde de compte fidélité.

Nous allons donc créer un nouveau composant, features/fidelity/Fidelity.js, que nous ajouterons à notre fichier app/App.js, et ajouter un dossier services dans lequel nous allons créer une API avec Query pour rechercher la valeur via une requête HTTP dans le fichier services/fidelityApi.js.

Voici comment j'implémente tout ça.

D'abord, on va créer notre service avec le contenu suivant :

import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';

export const api = createApi({
    reducerPath: 'fidelityApi',
    baseQuery: fetchBaseQuery({
        baseUrl: 'https://gist.githubusercontent.com/techerjeansebastienpro/'
    }),
    endpoints: (builder) => ({
        getFidelity: builder.query({
            query: () => `a71c41c32b5a4307217af02f31b02b3d/raw/19e6c2109fa56d75bb0140fc45fe7c52f5b6b40e/fidelity.json`,
        })
    })
});

export const { useGetFidelityQuery } = api;

Ensuite, dans notre store, on rajoute la ligne suivante dans les reducers :

[api.reducerPath]: api.reducer,

Ainsi que le middleware :

middleware: getDefaultMiddleware => getDefaultMiddleware().concat(api.middleware).concat(thunk),

Voici le contenu de notre nouveau composant :

import { useGetFidelityQuery } from "../../services/fidelityApi";

export const Fidelity = () => {
    const { data: fidelity, isLoading } = useGetFidelityQuery();
    return !isLoading && <div className="FidelityPoints">Vos points de fidélité s&#39;élèvent à { fidelity?.amount } points</div>;
};

En conclusion :

Notre application mérite d'aller plus loin, comme vous : vous pouvez encore approfondir Redux et Redux Toolkit, qui je l'espère, vous aideront à concrétiser beaucoup de projets bluffants.

Nous avons vu les bases de Redux, en passant par l'architecture Flux pour centraliser nos données avant d'ajouter Redux à React. Puis, nous avons vu ce que Redux Toolkit apporte pour rendre votre code plus efficace ! Enfin, nous venons de voir comment consommer des appels API grâce au store.

Vous avez maintenant tout ce qu'il faut pour mettre en place votre store et exploiter le potentiel de Reux.

C'est à vous de jouer !