IFT3225

Les enjeux des applications complexes

Jusqu’ici, une application React tenait dans un écran : quelques composants, un état local, des événements. Dès qu’une application grandit, de nouveaux besoins apparaissent.

Enjeu Solution (React)
Plusieurs écrans, une navigation Décomposer en vues (pages) et router avec React Router
Un état partagé entre des composants éloignés Le fournir par React Context, sans le faire descendre en props partout
Une structure qui tient à mesure que le code grandit Découper par responsabilité : vues, composants, contexte, services
Un public de plusieurs langues Externaliser les textes et basculer la langue avec react-i18next

Voici l’application Bus en vue, utilisée tout au long de ce guide.

Bus en vue

L'arrivée des autobus, entre usagers

Arrêt
Ligne
Arrive dans
Récents à cet arrêt
  • 11arrive dans ~3 minil y a 22 s
  • 361arrive dans ~7 minil y a 1 min

Décomposer en vues et React Router

Une application complexe se pense d’abord en vues : des pages ou espaces distinctes, chacun responsable d’une tâche.

Dans Bus en vue, chaque onglet peut être considéré comme une vue distincte :

React ne fournit pas lui-même de système de routage. On utilise couramment la bibliothèque React Router.

React Router associe une route (motif d’URL) à une vue. Étant du côté client, la navigation via les liens couverts par ces routes se fait sans rechargement de page.

import { BrowserRouter, Routes, Route, Link, useParams } from "react-router-dom";

function Application() {
  return (
    <BrowserRouter>
      <nav>
        <NavLink to="/" end>Signaler</NavLink>
        <NavLink to="/rechercher">Rechercher</NavLink>
        <NavLink to="/mes-arrets"> Mes arrêts </NavLink>
      </nav>
      <Routes>
        <Route path="/" element={<VueSignaler />} />
        <Route path="/rechercher" element={<VueRechercher />} />
        <Route path="/mes-arrets" element={<VueMesArrets />} />
        <Route path="/arret/:arretId" element={<VueArret />} />
      </Routes>
    </BrowserRouter>
  );
}

BrowserRouter relie React Router à l’URL et à l’historique du navigateur. À l’intérieur de celui-ci :

VérificationPourquoi souhaite t-on modifier l'URL?

Contrairement à un simple état (interne) représentant l’onglet courant, une URL peut être copiée, ajoutée aux favoris et retrouvée avec les boutons précédent et suivant du navigateur.

Paramètres d’URL

Dans le chemin suivant, le segment :arretId est un paramètre d’URL (partie dynamique de l’URL).

<Route path="/arrets/:arretId" element={<VueArret />} />

Une URL comme /arrets/18 associe donc la valeur “18” au paramètre arretId.

La vue peut lire ce paramètre avec useParams :

function VueArret() {
  const { arretId } = useParams();
  // ... afficher les signalements de cet arrêt
}

Les paramètres lus dans l’URL sont des chaînes de caractères. Il faut les convertir lorsqu’une valeur numérique est réellement nécessaire.

Les liens conviennent à la navigation effectuée directement par l’utilisateur.
Cependant, lorsqu’une navigation doit avoir lieu à la suite d’un traitement, par exemple après l’enregistrement réussi d’un signalement, on peut utiliser le hook useNavigate fourni par React Router.

import { useNavigate } from "react-router-dom";

const navigate = useNavigate();
navigate("/"); // retour à la liste

L’appel à navigate("/rechercher") ajoute normalement une nouvelle entrée à l’historique. Pour remplacer l’entrée courante, on peut utiliser :

navigate("/rechercher", { replace: true });

Un état partagé avec React Context

Jusqu’à présent, chaque état était placé dans le composant qui en avait besoin :

function Compteur() {
  const [nombre, setNombre] = useState(0);
  // ...
}

Lorsqu’un composant enfant a besoin de cette donnée, son parent la lui transmet avec une prop :

function Parent() {
  const [nombre, setNombre] = useState(0);

  return <Enfant nombre={nombre} />;
}

Quand plusieurs composants ont besoin du même état

Dans Bus en vue, certaines données ne concernent pas un seul composant :

En suivant cette approche, ces données doivent donc être placées dans un composant suffisamment haut pour être communes à toutes les vues qui les utilisent.

On pourrait ensuite les transmettre par props :

function Application() { 
  const [signalements, setSignalements] = useState([]); 

  return ( 
    <Coquille signalements={signalements}>
      <Routes signalements={signalements} />
    </Coquille> 
    ); 
}

Mais certains composants intermédiaires, comme Coquille, n’utilisent pas forcément signalements. Ils ne font que recevoir la prop pour la transmettre plus bas.

Application 
└── Coquille 
    └── Routes
        └── VueRechercher

Faire passer une donnée à travers plusieurs composants qui n’en ont pas eux-mêmes besoin est appelé prop drilling.

Sur un petit arbre, cela ne pose généralement aucun problème. Lorsque la donnée est utilisée dans de nombreuses branches éloignées, cette approche peut devenir répétitif et rendre les composants intermédiaires inutilement dépendants de cette donnée.

Le rôle de React Context

React Context permet à un composant (parent) de mettre une valeur à la disposition de tous ses descendants, sans devoir la transmettre manuellement à chaque niveau avec des props.

Context ne crée cependant pas l’état et ne le modifie pas. L’état continue d’être géré avec useState ou useReducer.

Pour mettre ce mécanisme en place, nous allons créer :

1. Créer le contexte et fournisseur

import { createContext, useContext, useState } from "react";

const AppContext = createContext(null);

function FournisseurApp({ children }) {
  const [signalements, setSignalements] = useState(/* ... */);
  const [favoris, setFavoris] = useState([]); 
  
  function signaler(nouveauSignalement) { 
    setSignalements((precedents) => [nouveauSignalement, ...precedents,]); 
  } 
  
  function basculerFavori(idArret) { 
    setFavoris((precedents) => precedents.includes(idArret) 
      ? precedents.filter((id) => id !== idArret) 
      : [...precedents, idArret] ); 
  }

  const voter = (idSignalement, direction) => { /* met à jour up / down */ };

  const valeur = { signalements, favoris, signaler, basculerFavori, voter };

  return (
    <AppContext.Provider value={valeur}>
      {children}
    </AppContext.Provider>
  );
}

Le fournisseur remplit deux rôles :

Les composants consommateurs n’ont donc pas besoin d’accéder directement à setSignalements ou à setFavoris.

2. Créer un hook d’accès

Pour éviter de répéter useContext(AppContext) dans chaque composant, on crée un hook personnalisé (useAppContext) qui enveloppe useContext :

export function useAppContext() {
  const contexte = useContext(AppContext);

  if (contexte === null) {
    throw new Error(
      "useAppContext doit être utilisé dans <FournisseurApp>."
    );
  }

  return contexte;
}

Cette vérification produit une erreur claire si un composant essaie d’utiliser useAppContext en dehors du fournisseur.

3. Installer le fournisseur

Le fournisseur doit entourer tous les composants qui utilisent useAppContext.

Dans cette application, toutes les vues peuvent avoir besoin de l’état partagé. On peut donc placer le fournisseur autour du routeur :

export default function BusSignalApp() {
  return (
    <FournisseurApp>
      <MemoryRouter initialEntries={["/"]}>
        <Coquille />
      </MemoryRouter>
    </FournisseurApp>
  );
}

MemoryRouter est ici le routeur choisi par l’application. Son utilisation est indépendante de Context : le fournisseur fonctionnerait de la même manière avec un autre type de routeur.

4. Consommer le contexte dans une vue

Une vue récupère uniquement les données et les actions dont elle a besoin :

function VueMesArrets() {
  const { favoris, signalements, basculerFavori, } = useAppContext();

  const signalementsDesFavoris = signalements.filter(
    (signalement) => favoris.includes(signalement.idArret)
  );

  // Afficher les arrêts favoris
  // et les signalements qui leur sont associés.
}

Il n’est plus nécessaire de transmettre favoris et signalements à travers tous les composants intermédiaires.

Cela ne signifie pas que tous les props de nos composants disparaissent. Les props restent utiles pour transmettre des informations locales et explicites entre un parent et son enfant.

Le contexte sert à un état largement partagé (les signalements, les favoris, les votes). L’état local d’une vue, lui, reste gérer en useState . Dans la vue Signaler, l’arrêt, la ligne et le délai en cours de sélection n’intéressent personne d’autre, ils n’ont aucune raison de monter dans le contexte.

Structurer le projet

Chaque responsabilité possède un emplacement clairement identifié. Cette organisation facilite la compréhension du projet, sa maintenance et son évolution.

src/
├── App.jsx              // fournisseurs et structure principale de l'application
├── routes.jsx           // table des routes (chemin vers vue)
├── pages/               // une vue par écran routé
│   ├── VueSignaler.jsx
│   ├── VueRechercher.jsx
│   ├── VueMesArrets.jsx
│   └── VueArret.jsx
├── components/          // composants d'interface aux responsabilités délimitées
│   ├── Coquille.jsx
│   ├── Signalement.jsx
│   ├── Vote.jsx
│   └── EtoileFavori.jsx
├── context/             // état et actions partagés
│   ├── AppContext.js    // création du contexte et hook useAppContext
│   └── FournisseurApp.jsx
├── hooks/               // hooks personnalisés (ex: useLocalStorage)
├── services/            // communication avec les sources de données (externes)
│   └── api.js           
├── i18n/                // configuration et ressources de traduction
│   ├── index.js         
│   └── locales/
│       ├── fr.json
│       └── en.json
└── data/                // constantes et données de démonstration

App.jsx assemble les différentes parties de l’application. Il place les fournisseurs au-dessus des composants qui en ont besoin, puis installe le routeur et la structure principale.

Ce découpage devient surtout utile quand l’application évolue :

Cette arborescence est organisée principalement par type de responsabilité. Elle convient bien à une application de cette taille. Dans un projet plus vaste, on pourrait regrouper certains fichiers par fonctionnalité, par exemple tout ce qui concerne les signalements ou les favoris.

Récapitulatif

Notion Rôle Outils clés
Vues et navigation Découper l’application en écrans liés à des URL BrowserRouter, Routes, Route, Link, useParams, useNavigate
État partagé Fournir une donnée centrale sans prop drilling createContext, Provider, useContext, hook useSignalements
Structure Une responsabilité par dossier, pour la maintenance et l’évolution pages/, components/, context/, services/, i18n/