Skip to content

4. Le routage

4.1. Exemple 17 : le routeur

Une application [React] est une application à page unique : le navigateur ne charge qu'UNE page HTML. Quand on clique sur un lien interne, aucune requête n'est envoyée au serveur : le routeur change l'adresse affichée par le navigateur, et remplace le composant affiché. [React] n'a pas de routeur : on utilise la bibliothèque [React Router] (paquet [react-router]), la plus répandue.

[React Router] s'utilise de trois manières (ses « modes ») : déclarative (des composants [<Routes>] et [<Route>], le plus simple), « données » (les routes décrites par des objets, avec des fonctions exécutées avant l'affichage : [loader], [middleware]...), et « framework » (une application complète, avec rendu côté serveur, à la manière de [Next.js]). Nous utilisons le mode données, qui offre les gardes de navigation dont nous aurons besoin.

4.1.1. Les données

// des données "en dur", pour ne pas dépendre d'un serveur dans cet exemple
export interface Medecin {
  id: number;
  titre: string;
  prenom: string;
  nom: string;
  specialite: string;
}

export const MEDECINS: Medecin[] = [
  { id: 1, titre: 'Mme', prenom: 'Marie', nom: 'PELISSIER', specialite: 'généraliste' },
  { id: 2, titre: 'Mr', prenom: 'Paul', nom: 'TATOU', specialite: 'pédiatre' },
  { id: 3, titre: 'Melle', prenom: 'Sophie', nom: 'DUPONT', specialite: 'généraliste' },
];

Des données « en dur », pour ne pas dépendre d'un serveur dans cet exemple.

4.1.2. Les routes

// =========================================================================
// 17-routeur/src/routes.tsx - les ROUTES de l'application
// -------------------------------------------------------------------------
// Une application React est une application à page unique (SPA : Single
// Page Application) : le navigateur ne charge qu'UNE page HTML. Quand on
// clique sur un lien interne, aucune requête n'est envoyée au serveur : le
// routeur change l'adresse affichée par le navigateur, et remplace le
// composant affiché dans <Outlet />.
//
// React n'a pas de routeur : on utilise la bibliothèque React Router. Une
// route associe un CHEMIN à un COMPOSANT (une "page") :
//   'medecins/:id'  :id est un paramètre (/medecins/2 -> id = "2") ;
//   '*'             n'importe quel chemin : la page "introuvable" (le
//                   routeur choisit toujours la route la plus précise).
// Les routes sont IMBRIQUÉES : la route racine affiche la coquille de
// l'application (Coquille), et ses enfants s'affichent à l'intérieur, à
// l'emplacement de son <Outlet />.
// =========================================================================

import { redirect, type RouteObject } from 'react-router';
import Coquille from './Coquille';
import Accueil from './pages/Accueil';
import ListeMedecins from './pages/ListeMedecins';
import DetailMedecin from './pages/DetailMedecin';
import Recherche from './pages/Recherche';
import PageIntrouvable from './pages/PageIntrouvable';

export const routes: RouteObject[] = [
  {
    path: '/',
    Component: Coquille,
    children: [
      // index : la route de l'adresse du parent elle-même ("/")
      { index: true, Component: Accueil },
      { path: 'medecins', Component: ListeMedecins },
      { path: 'medecins/:id', Component: DetailMedecin },
      { path: 'recherche', Component: Recherche },
      // chargement DIFFÉRÉ : le code de cette page n'est téléchargé qu'à la
      // première visite (Vite en fait un fichier à part)
      { path: 'a-propos', lazy: async () => ({ Component: (await import('./pages/APropos')).default }) },
      // une redirection : l'ancienne adresse mène à la nouvelle. Un LOADER
      // est une fonction que le routeur appelle AVANT d'afficher la route ;
      // s'il lance (throw) une redirection, la navigation change de cible
      {
        path: 'docteurs',
        loader: () => {
          throw redirect('/medecins');
        },
      },
      { path: '*', Component: PageIntrouvable },
    ],
  },
];
  • ligne 20 : [RouteObject] est le type d'une route ; [redirect] fabrique une redirection ;
  • lignes 28-53 : les routes sont des objets : un chemin ([path]) et un composant ([Component], avec une majuscule : on donne le composant, pas [<Accueil />]) ;
  • lignes 30-32 : les routes sont imbriquées : la route racine affiche la « coquille » de l'application ([Coquille]), et ses enfants ([children]) s'affichent À L'INTÉRIEUR de la coquille, à l'emplacement de son [<Outlet />]. Les chemins des enfants sont relatifs à celui du parent ;
  • ligne 34 : une route index : celle de l'adresse du parent elle-même (« / ») ;
  • ligne 36 : [:id] est un paramètre : [/medecins/2] affiche [DetailMedecin], avec [id] = « 2 » ;
  • ligne 40 : un chargement différé : la fonction [lazy] n'est appelée qu'à la première visite de la page. [import('./pages/APropos')] (un import « dynamique », sous forme de fonction) télécharge alors le module, que [Vite] a rangé dans un fichier à part. [lazy] renvoie les propriétés manquantes de la route : ici, son composant, exporté par défaut par le module ;
  • lignes 45-49 : une redirection : l'ancienne adresse mène à la nouvelle. Un loader est une fonction que le routeur appelle AVANT d'afficher la route (pour charger ses données) ; s'il lance ([throw]) une redirection, la navigation change de cible ;
  • ligne 50 : [*] correspond à n'importe quel chemin : la page « introuvable ». Le routeur choisit toujours la route la plus précise : l'ordre des routes n'a pas d'importance.

4.1.3. Le démarrage

// =========================================================================
// 17-routeur/src/main.tsx
// -------------------------------------------------------------------------
// createBrowserRouter(routes) fabrique le routeur, qui suit l'adresse du
// navigateur ; <RouterProvider> affiche la route qui correspond à l'adresse
// courante. Le composant racine n'est plus App : ce sont les routes.
// =========================================================================

import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { createBrowserRouter } from 'react-router';
import { RouterProvider } from 'react-router/dom';
import 'bootstrap/dist/css/bootstrap.min.css';
import { routes } from './routes';

const routeur = createBrowserRouter(routes);

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <RouterProvider router={routeur} />
  </StrictMode>,
);
  • ligne 16 : le routeur, fabriqué à partir des routes. [Browser] : il suit l'adresse du navigateur, par l'API [history] ;
  • ligne 20 : le composant qui affiche la route correspondant à l'adresse courante. Le composant racine n'est plus [App] : ce sont les routes. [RouterProvider] est importé de [react-router/dom], la partie de la bibliothèque propre aux navigateurs.

4.1.4. La coquille de l'application

// =========================================================================
// 17-routeur/src/Coquille.tsx - la "coquille" de l'application
// -------------------------------------------------------------------------
//   - <Link to="...">, <NavLink to="..."> : un lien interne. Il produit un
//     vrai <a href>, mais un clic ne recharge pas la page : c'est le routeur
//     qui agit. NavLink sait en plus s'il désigne la page affichée : il
//     pose alors la classe "active" (celle de Bootstrap) ;
//   - <Outlet /> : l'emplacement où s'affiche la route ENFANT courante ;
//   - useLocation() : l'adresse courante (pathname, search...).
// =========================================================================

import { NavLink, Outlet, useLocation } from 'react-router';

export default function Coquille() {
  const location = useLocation();
  // decodeURIComponent : %C3%A9 redevient é
  const adresse = decodeURIComponent(location.pathname + location.search);

  return (
    <div className="container py-4">
      <h1 className="h3 mb-3">Exemple 17 - le routeur</h1>
      <nav className="nav nav-pills mb-3">
        {/* end : "/" n'est active que sur "/" exactement, et non sur /medecins */}
        <NavLink className="nav-link" to="/" end>Accueil</NavLink>
        <NavLink className="nav-link" to="/medecins">Médecins</NavLink>
        {/* les paramètres après le ? : NavLink compare seulement le chemin */}
        <NavLink className="nav-link" to="/recherche?q=généraliste">Recherche</NavLink>
        <NavLink className="nav-link" to="/a-propos">À propos</NavLink>
        <NavLink className="nav-link" to="/docteurs">/docteurs (redirection)</NavLink>
        <NavLink className="nav-link" to="/page-inconnue">Lien cassé</NavLink>
      </nav>
      <p className="small text-muted">adresse courante : <code>{adresse}</code></p>
      <div className="card p-3">
        <Outlet />
      </div>
    </div>
  );
}
  • ligne 15 : [useLocation()] donne l'adresse courante : [pathname] (le chemin), [search] (la partie après le « ? »)... Le composant est rappelé à chaque navigation ;
  • ligne 24 : [<NavLink to="...">] est un lien interne. Il produit un vrai [<a href>] (on peut l'ouvrir dans un nouvel onglet), mais un clic ne recharge pas la page : c'est le routeur qui agit. [NavLink] sait en plus s'il désigne la page affichée : il ajoute alors la classe [active] (celle de [Bootstrap]). [end] : la page d'accueil « / » n'est active que sur « / » exactement, et non sur [/medecins] ; [<Link>] est la version simple, sans classe active ;
  • ligne 27 : une adresse avec des paramètres. [NavLink] ne compare que le chemin : le lien reste actif quelle que soit la recherche ;
  • ligne 34 : l'emplacement où s'affiche la route ENFANT courante.

4.1.5. Les pages

La page d'accueil :

// React 19 : un élément <title> écrit dans un composant est placé par React
// dans le <head> de la page ; il change le titre de l'onglet tant que le
// composant est affiché
export default function Accueil() {
  return (
    <>
      <title>Accueil</title>
      <p className="mb-0">
        Bienvenue. Cliquez sur les liens : l'adresse du navigateur change, la page n'est jamais rechargée (ouvrez
        l'onglet Réseau des outils de développement pour le vérifier). Les boutons Précédent et Suivant du navigateur
        fonctionnent, ainsi que le rechargement (F5) de n'importe quelle page.
      </p>
    </>
  );
}
  • ligne 7 : [React] 19 place dans le [<head>] de la page tout élément [<title>] écrit dans un composant, tant que le composant est affiché : chaque page fixe ainsi le titre de l'onglet (on peut faire de même avec [<meta>] et [<link>]).

La liste des médecins, avec des liens à paramètre :

// 17-routeur/src/pages/ListeMedecins.tsx - des liens avec paramètre
import { Link } from 'react-router';
import { MEDECINS } from '../donnees';

export default function ListeMedecins() {
  return (
    <>
      <title>Médecins</title>
      <ul className="mb-0">
        {MEDECINS.map((m) => (
          // un gabarit de chaîne fabrique l'adresse /medecins/2
          <li key={m.id}>
            <Link to={`/medecins/${m.id}`}>{m.titre} {m.prenom} {m.nom}</Link>
          </li>
        ))}
      </ul>
    </>
  );
}

Un gabarit de chaîne fabrique l'adresse [/medecins/2].

Le détail d'un médecin :

// =========================================================================
// 17-routeur/src/pages/DetailMedecin.tsx - une page à paramètre
// -------------------------------------------------------------------------
// useParams() : les paramètres du chemin, ici { id: "2" } - toujours des
// CHAÎNES. useNavigate() : une fonction pour naviguer PAR LE CODE ;
// navigate(-1) revient à la page précédente, comme le bouton Précédent.
// Si l'on passe de /medecins/1 à /medecins/2, React RÉUTILISE le même
// composant : il est simplement rappelé avec un autre id.
// =========================================================================

import { useNavigate, useParams } from 'react-router';
import { MEDECINS } from '../donnees';

export default function DetailMedecin() {
  const { id } = useParams();
  const navigate = useNavigate();
  const medecin = MEDECINS.find((m) => m.id === Number(id));

  if (!medecin) {
    return <p className="text-danger mb-0">Le médecin n° {id} n'existe pas.</p>;
  }

  const suivant = (medecin.id % MEDECINS.length) + 1;
  return (
    <>
      <title>{`${medecin.prenom} ${medecin.nom}`}</title>
      <h2 className="h5">{medecin.titre} {medecin.prenom} {medecin.nom}</h2>
      <p>Spécialité : {medecin.specialite}</p>
      <div className="d-flex gap-2">
        <button className="btn btn-sm btn-outline-secondary" onClick={() => navigate(-1)}>Retour</button>
        <button className="btn btn-sm btn-outline-primary" onClick={() => navigate(`/medecins/${suivant}`)}>Médecin suivant</button>
      </div>
    </>
  );
}
  • ligne 15 : [useParams()] donne les paramètres du chemin, ici [{ id: "2" }], toujours sous forme de chaînes ;
  • ligne 16 : une fonction pour naviguer par le code ;
  • lignes 19-21 : un médecin inconnu ([/medecins/9]) : un message ;
  • ligne 26 : un titre calculé : [<title>] n'accepte qu'UNE chaîne, d'où le gabarit ;
  • ligne 30 : [navigate(-1)] revient à la page précédente, comme le bouton Précédent du navigateur ;
  • ligne 31 : naviguer vers une adresse. Passer de [/medecins/1] à [/medecins/2] ne recrée pas le composant : [React] le rappelle simplement, avec un autre [id].

La recherche :

// =========================================================================
// 17-routeur/src/pages/Recherche.tsx - une page pilotée par l'URL
// -------------------------------------------------------------------------
// /recherche?q=pédiatre : comme avec le formulaire GET du cours NestJS, la
// page est entièrement décrite par son adresse : on peut la mettre en
// favori, la recharger, revenir en arrière. useSearchParams() donne les
// paramètres après le ?.
// <Form> (de React Router) est un formulaire GET... sans rechargement : le
// routeur navigue vers /recherche?q=<saisie>. La page affiche ce que dit
// l'URL, et rien d'autre.
// =========================================================================

import { Form, useSearchParams } from 'react-router';
import { MEDECINS } from '../donnees';

export default function Recherche() {
  const [parametres] = useSearchParams();
  const q = parametres.get('q') ?? ''; // null s'il est absent
  const resultats = MEDECINS.filter((m) => `${m.prenom} ${m.nom} ${m.specialite}`.toLowerCase().includes(q.toLowerCase()));

  return (
    <>
      <title>Recherche</title>
      <Form className="d-flex gap-2 mb-3" action="/recherche">
        {/* un champ NON contrôlé, qui démarre avec la valeur de l'URL ;
            key={q} : si l'URL change (bouton Précédent), React recrée le
            champ, qui reprend alors la nouvelle valeur de l'URL */}
        <input key={q} name="q" className="form-control" placeholder="nom ou spécialité" defaultValue={q} />
        <button className="btn btn-primary">Rechercher</button>
      </Form>
      <ul className="mb-0">
        {resultats.map((m) => (
          <li key={m.id}>{m.prenom} {m.nom} ({m.specialite})</li>
        ))}
        {resultats.length === 0 && <li className="text-muted">Aucun résultat.</li>}
      </ul>
    </>
  );
}
  • ligne 17 : les paramètres après le « ? », sous la forme d'un objet [URLSearchParams] : [parametres.get('q')] vaut [null] si [q] est absent. [useSearchParams] renvoie aussi (en second élément) une fonction pour les changer ;
  • ligne 24 : [<Form>] (de [React Router]) est un formulaire GET... sans rechargement : à l'envoi, le routeur navigue vers [/recherche?q=<saisie>] ([q] étant le [name] du champ). Comme avec le formulaire GET du cours [NestJS], la page est entièrement décrite par son adresse : on peut la mettre en favori, la recharger, revenir en arrière ;
  • ligne 28 : le champ n'est pas contrôlé : il démarre avec la valeur de l'adresse ([defaultValue]). Si l'adresse change sans que la page soit recréée (bouton Précédent), le champ garderait son ancien texte : [key={q}] règle ce problème. Quand la clé d'un élément change, [React] le considère comme un AUTRE élément : il détruit l'ancien et en crée un nouveau, qui repart de la nouvelle valeur par défaut. C'est une astuce courante pour « remettre à zéro » un composant.

La page chargée à la demande, et la page introuvable :

// 17-routeur/src/pages/APropos.tsx - une page chargée à la demande
// "export default" : la route la retrouve par (await import(...)).default
export default function APropos() {
  return (
    <>
      <title>À propos</title>
      <p className="mb-0">
        Cette page a été téléchargée à la première visite (onglet Réseau : un fichier APropos-XXXX.js en production).
        Son titre d'onglet vient de l'élément title qu'elle contient.
      </p>
    </>
  );
}
import { useLocation } from 'react-router';

export default function PageIntrouvable() {
  const { pathname } = useLocation();
  return (
    <>
      <title>Page introuvable</title>
      <p className="text-danger mb-0">Aucune page à l'adresse <code>{pathname}</code>.</p>
    </>
  );
}

4.1.6. L'exécution

On clique sur « Médecins », puis sur « Mr Paul TATOU » : l'adresse devient [/medecins/2], le titre de l'onglet « Paul TATOU » :

Image

Une recherche : l'adresse devient [/recherche?q=pédiatre] :

Image

« À propos » : en développement, le module [APropos.tsx] n'est téléchargé qu'à la première visite (onglet Réseau des outils de développement) ; en production ([npm run build 17-routeur]), il est rangé dans un fichier à part :

1
2
3
4
17-routeur/dist/index.html                    0.38 kB │ gzip:   0.26 kB
17-routeur/dist/assets/index-CqLr8KVv.css   230.06 kB │ gzip:  30.68 kB
17-routeur/dist/assets/APropos-eTMMSx0r.js    0.39 kB │ gzip:   0.30 kB
17-routeur/dist/assets/index-CC9g13SZ.js    317.09 kB │ gzip: 100.19 kB

Image

« /docteurs » mène à [/medecins] ; un lien cassé :

Image

On peut recharger (F5) n'importe quelle page : le serveur de développement de [Vite] renvoie [index.html] pour toute adresse inconnue, et le routeur affiche la bonne page. En production, le serveur devra faire de même (cf. chapitre « Déploiement »).

4.2. Exemple 18 : les gardes de navigation

Certaines pages exigent d'être connecté, ou d'avoir un certain rôle. Le routeur peut le vérifier avant de les afficher, grâce aux middlewares.

4.2.1. La session

// =========================================================================
// 18-gardes/src/session.ts - une session SIMULÉE (sans serveur)
// -------------------------------------------------------------------------
// L'utilisateur connecté doit être connu des COMPOSANTS (la barre du haut,
// la page de l'agenda) et des GARDES (des fonctions du routeur, qui ne sont
// pas des composants et ne peuvent donc pas lire un contexte). On le range
// dans un simple module, un "magasin" (store) fait main :
//   - une variable privée, l'état ;
//   - des fonctions pour le lire et le modifier ;
//   - subscribe : la liste des fonctions à prévenir quand il change.
// Un composant s'y abonne avec useSyncExternalStore (cf. useSession) : il
// est rappelé quand l'état change. L'exemple 20 confiera ce travail à une
// bibliothèque, Zustand. (L'exemple 23 fera la même chose avec un vrai
// serveur.)
// =========================================================================

import { useSyncExternalStore } from 'react';

export type Role = 'ADMIN' | 'DOCTOR' | 'USER';

export interface Utilisateur {
  login: string;
  role: Role;
}

// trois comptes "en dur" (mot de passe = login)
const COMPTES: Record<string, Role> = { admin: 'ADMIN', pelissier: 'DOCTOR', martin: 'USER' };

let utilisateur: Utilisateur | null = null; // l'état
const abonnes = new Set<() => void>();

function changer(u: Utilisateur | null) {
  utilisateur = u;
  abonnes.forEach((prevenir) => prevenir());
}

export const session = {
  utilisateur: () => utilisateur,
  connecter(login: string, password: string): boolean {
    const role = Object.hasOwn(COMPTES, login) ? COMPTES[login] : undefined;
    if (role === undefined || password !== login) {
      return false;
    }
    changer({ login, role });
    return true;
  },
  deconnecter: () => changer(null),
  subscribe(prevenir: () => void) {
    abonnes.add(prevenir);
    return () => abonnes.delete(prevenir); // pour se désabonner
  },
};

// le hook des composants : l'utilisateur connecté, toujours à jour
export function useSession(): Utilisateur | null {
  return useSyncExternalStore(session.subscribe, session.utilisateur);
}

L'utilisateur connecté doit être connu des composants (la barre du haut, la page de l'agenda) et des gardes, des fonctions du routeur qui ne sont pas des composants et ne peuvent donc pas lire un contexte. On le range dans un simple module, un magasin ([store]) écrit à la main :

  • lignes 29-30 : l'état, une variable privée du module, et la liste des fonctions à prévenir quand il change ;
  • lignes 32-35 : changer l'état, et prévenir les abonnés ;
  • lignes 37-52 : les fonctions publiques : lire l'utilisateur, se connecter (trois comptes « en dur », mot de passe = login), se déconnecter, et s'abonner ([subscribe] renvoie la fonction qui désabonne) ;
  • lignes 55-57 : le hook des composants. [useSyncExternalStore(subscribe, lire)] est le hook de [React] fait pour cela : il abonne le composant au magasin, et le rappelle quand la valeur lue change.

L'exemple 20 confiera ce travail à une bibliothèque.

4.2.2. Les gardes

// =========================================================================
// 18-gardes/src/gardes.ts - des GARDES de navigation
// -------------------------------------------------------------------------
// Un MIDDLEWARE de React Router est une fonction que le routeur appelle
// AVANT d'afficher une route qui le déclare (middleware: [...]), à CHAQUE
// navigation vers elle. Il peut :
//   - ne rien faire : la navigation continue ;
//   - lancer (throw) une redirection, redirect('/adresse') : la
//     navigation est remplacée par une navigation vers cette adresse.
// Il reçoit la requête de navigation (request.url : l'adresse demandée).
//
// C'est l'équivalent, côté client, des gardes NestJS (JwtAuthGuard,
// RolesGuard) - avec une différence FONDAMENTALE : un garde du client n'est
// qu'un confort. Le code du client est entre les mains de l'utilisateur, qui
// peut le modifier à volonté. La vraie protection des données reste celle
// du SERVEUR (cf. chapitre sur la boîte noire).
// =========================================================================

import { redirect, type MiddlewareFunction } from 'react-router';
import { session, type Role } from './session';

// l'adresse demandée, sans la machine : "/agenda?x=1"
function adresse(request: Request): string {
  const url = new URL(request.url);
  return url.pathname + url.search;
}

// la page exige d'être connecté
export const connexionRequise: MiddlewareFunction = ({ request }) => {
  if (session.utilisateur() === null) {
    // pas connecté : à la page de connexion, en retenant où l'on voulait aller
    throw redirect(`/connexion?redirect=${encodeURIComponent(adresse(request))}`);
  }
};

// la page exige l'un des rôles donnés : une fonction qui FABRIQUE le garde
export function roleRequis(...roles: Role[]): MiddlewareFunction {
  return ({ request }) => {
    const u = session.utilisateur();
    if (u === null || !roles.includes(u.role)) {
      // connecté, mais pas avec le bon rôle : retour à l'accueil, avec un motif
      throw redirect(`/?refus=${encodeURIComponent(adresse(request))}`);
    }
  };
}

Un middleware de [React Router] est une fonction que le routeur appelle AVANT d'afficher une route qui le déclare, à CHAQUE navigation vers elle. Il peut ne rien faire (la navigation continue), ou lancer une redirection, qui remplace la navigation.

  • lignes 23-26 : il reçoit la requête de navigation : [request.url] est l'adresse complète demandée ([http://localhost:5173/agenda?x=1]) ; on n'en garde que le chemin et les paramètres ;
  • lignes 29-34 : pas connecté : à la page de connexion, en retenant où l'on voulait aller ([?redirect=...]) ;
  • lignes 37-45 : une fonction qui FABRIQUE un garde, pour les rôles donnés ; [...roles] : un nombre quelconque d'arguments, réunis en un tableau. Connecté, mais pas avec le bon rôle : retour à l'accueil, avec un motif.

C'est l'équivalent, côté client, des gardes [NestJS] ([JwtAuthGuard], [RolesGuard]), avec une différence FONDAMENTALE : un garde du client n'est qu'un confort. Le code du client est entre les mains de l'utilisateur, qui peut le modifier à volonté. La vraie protection des données reste celle du SERVEUR (cf. chapitre sur la boîte noire).

4.2.3. Les routes protégées

// =========================================================================
// 18-gardes/src/routes.tsx - des routes PROTÉGÉES
// -------------------------------------------------------------------------
// middleware : les gardes à franchir, dans l'ordre. Une page chargée à la
// demande (lazy) n'est téléchargée qu'une fois les gardes franchis.
// =========================================================================

import { redirect, type RouteObject } from 'react-router';
import Coquille from './Coquille';
import Accueil from './pages/Accueil';
import Connexion from './pages/Connexion';
import Agenda from './pages/Agenda';
import { connexionRequise, roleRequis } from './gardes';

export const routes: RouteObject[] = [
  {
    path: '/',
    Component: Coquille,
    children: [
      { index: true, Component: Accueil },
      { path: 'connexion', Component: Connexion },
      { path: 'agenda', Component: Agenda, middleware: [connexionRequise] },
      {
        path: 'administration',
        middleware: [connexionRequise, roleRequis('ADMIN')],
        lazy: async () => ({ Component: (await import('./pages/Administration')).default }),
      },
      // toute autre adresse : retour à l'accueil
      { path: '*', loader: () => redirect('/') },
    ],
  },
];
  • ligne 22 : les gardes d'une route, exécutés dans l'ordre ;
  • lignes 24-26 : une page chargée à la demande n'est téléchargée qu'une fois les gardes franchis ;
  • ligne 29 : toute autre adresse : retour à l'accueil. Un loader peut aussi RENVOYER la redirection.

Le démarrage est celui de l'exemple 17 :

import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { createBrowserRouter } from 'react-router';
import { RouterProvider } from 'react-router/dom';
import 'bootstrap/dist/css/bootstrap.min.css';
import { routes } from './routes';

const routeur = createBrowserRouter(routes);

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <RouterProvider router={routeur} />
  </StrictMode>,
);

4.2.4. La coquille et les pages

// 18-gardes/src/Coquille.tsx - les liens, et l'utilisateur connecté
import { NavLink, Outlet, useNavigate } from 'react-router';
import { session, useSession } from './session';

// NavLink accepte une FONCTION pour className : elle reçoit { isActive }
const classe = ({ isActive }: { isActive: boolean }) => (isActive ? 'fw-bold' : '');

export default function Coquille() {
  const utilisateur = useSession();
  const navigate = useNavigate();

  function seDeconnecter() {
    session.deconnecter();
    navigate('/');
  }

  return (
    <div className="container py-4" style={{ maxWidth: 720 }}>
      <h1 className="h3 mb-3">Exemple 18 - les gardes de navigation</h1>
      <nav className="d-flex gap-3 align-items-center mb-3">
        <NavLink to="/" end className={classe}>Accueil</NavLink>
        <NavLink to="/agenda" className={classe}>Agenda (connecté)</NavLink>
        <NavLink to="/administration" className={classe}>Administration (ADMIN)</NavLink>
        <span className="ms-auto">
          {utilisateur ? (
            <>
              {utilisateur.login} <span className="badge text-bg-primary">{utilisateur.role}</span>
              <button className="btn btn-sm btn-link" onClick={seDeconnecter}>déconnexion</button>
            </>
          ) : (
            <NavLink to="/connexion">connexion</NavLink>
          )}
        </span>
      </nav>
      <div className="card p-3"><Outlet /></div>
    </div>
  );
}
  • ligne 6 : [className] d'un [NavLink] peut être une fonction, qui reçoit [{ isActive }] et renvoie les classes ;
  • ligne 9 : l'utilisateur connecté ; la barre suit ses changements ;
  • lignes 12-15 : déconnexion, et retour à l'accueil.
import { useSearchParams } from 'react-router';

export default function Accueil() {
  // ?refus=... : déposé par le garde roleRequis quand le rôle ne convient pas
  const refus = useSearchParams()[0].get('refus');
  return (
    <>
      {refus && <p className="alert alert-danger">Votre rôle ne permet pas d'ouvrir {refus}.</p>}
      <p className="mb-0">
        Page publique. Essayez les deux autres liens, connecté ou non (admin, pelissier, martin - mot de passe = login).
      </p>
    </>
  );
}
import { useSession } from '../session';

export default function Agenda() {
  const utilisateur = useSession();
  return (
    <p className="mb-0">
      L'agenda de <strong>{utilisateur?.login}</strong> : cette page exige d'être connecté.
    </p>
  );
}
1
2
3
4
5
6
7
8
export default function Administration() {
  return (
    <p className="mb-0">
      Administration : page réservée au rôle ADMIN, chargée à la demande (onglet Réseau : son fichier n'est téléchargé
      qu'à la première visite, et seulement si les gardes l'ont permis).
    </p>
  );
}
// =========================================================================
// 18-gardes/src/pages/Connexion.tsx
// -------------------------------------------------------------------------
// Après la connexion, on renvoie l'utilisateur à la page qu'il voulait
// ouvrir (paramètre ?redirect=... déposé par le garde), ou à l'accueil.
// =========================================================================

import { useState, type FormEvent } from 'react';
import { useNavigate, useSearchParams } from 'react-router';
import { session } from '../session';

export default function Connexion() {
  const redirection = useSearchParams()[0].get('redirect');
  const navigate = useNavigate();
  const [login, setLogin] = useState('');
  const [password, setPassword] = useState('');
  const [erreur, setErreur] = useState(false);

  function valider(e: FormEvent<HTMLFormElement>) {
    e.preventDefault();
    if (!session.connecter(login.trim(), password)) {
      setErreur(true);
      return;
    }
    // on n'accepte qu'un chemin INTERNE : il commence par /, mais pas par //
    // (//site.com/... désigne un AUTRE site) - pas de redirection vers un autre site
    const interne = redirection !== null && redirection.startsWith('/') && !redirection.startsWith('//');
    navigate(interne ? redirection : '/');
  }

  return (
    <>
      {redirection && <p className="alert alert-warning">Connectez-vous pour ouvrir {redirection}.</p>}
      <form className="d-flex flex-wrap gap-2" onSubmit={valider}>
        <input className="form-control w-auto" placeholder="login" autoComplete="username" value={login}
               onChange={(e) => setLogin(e.target.value)} />
        <input type="password" className="form-control w-auto" placeholder="mot de passe" autoComplete="current-password"
               value={password} onChange={(e) => setPassword(e.target.value)} />
        <button className="btn btn-primary">Se connecter</button>
      </form>
      {erreur && <p className="text-danger mt-2 mb-0">Login ou mot de passe incorrect.</p>}
    </>
  );
}
  • ligne 13 : l'adresse que l'on voulait ouvrir, déposée par le garde ;
  • lignes 27-28 : après la connexion, on y va... à condition que ce soit un chemin INTERNE (il commence par « / », mais pas par « // », qui désigne un autre site) : une adresse de connexion piégée ([/connexion?redirect=https://site-malveillant.fr]) ne doit pas mener hors de l'application.

4.2.5. L'exécution

Sans être connecté, on clique sur « Administration » : le garde renvoie à la page de connexion :

Image

On se connecte comme [martin] (rôle [USER]) : le garde des rôles renvoie à l'accueil :

Image

Connecté comme [admin] :

Image