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.
L'arrivée des autobus, entre usagers
- 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 :
- Signaler un problème ;
- Rechercher des signalements ;
- Consulter Mes arrêts, c’est-à-dire les arrêts placés en favoris ;
- Afficher le détail d’un arrêt précis.
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 :
Routescontient la configuration des routes ;- Chaque
Routeassocie un chemin à l’élément à afficher ; NavLinkpermet de naviguer sans demander un nouveau document HTML au serveur.
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.
Naviguer après une action
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 :
- plusieurs vues affichent les mêmes signalements ;
- les favoris sont utilisés dans la recherche et dans Mes arrêts ;
- un vote effectué dans une vue doit être visible dans les autres.
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 :
- un contexte, qui représente la valeur partagée ;
- un fournisseur, qui possède l’état et fournit sa valeur ;
- un hook personnalisé, qui permet aux composants d’accéder facilement au contexte.
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 :
- il possède l’état partagé;
- il expose les actions autorisées pour modifier cet état.
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>
);
}
MemoryRouterest 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 :
- Ajouter un écran : créer une vue dans
pages/, l’associer à un chemin dansroutes.jsx, puis ajouter au besoin un lien dans la navigation. - Brancher un serveur réel : remplacer l’implémentation simulée de
services/api.jspar des requêtes réseau tout en conservant la même interface pour le reste de l’application. - Mémoriser une préférence : utiliser useLocalStorage à l’endroit où la préférence est possédée, sans modifier les composants qui la consomment.
- Ajouter une langue : ajouter ses ressources dans
i18n/sans modifier la structure des composants.
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/ |