Cours JavaScript et React · Module 6: React Router

Gérer l'historique du navigateur lors d'un voyage à travers le temps cosmique

12 min de lecture
Dans cette leçon12

Dans les voyages intergalactiques, il est important de suivre la trajectoire et de pouvoir revenir aux points d'exploration précédents. Dans le monde de React Router, ce système de navigation est l'historique du navigateur - un mécanisme qui permet de se déplacer en avant et en arrière entre les pages visitées. Voyons comment gérer consciemment l'historique pour améliorer le confort des voyages des utilisateurs à travers notre application.

Les bases de l'historique du navigateur

L'historique du navigateur est une pile d'adresses URL visitées par l'utilisateur. Les navigateurs fournissent une API qui permet de :

  • Se déplacer en avant et en arrière entre les pages
  • Ajouter de nouvelles entrées à l'historique
  • Remplacer l'entrée actuelle
  • Manipuler l'état de l'historique

React Router s'intègre à cette API, fournissant des hooks et des composants pour gérer l'historique d'une manière adaptée à React.

Le hook useNavigate

Le hook useNavigate est l'outil principal pour la navigation programmatique dans React Router :

1import { useNavigate } from 'react-router-dom';
2
3function MissionControl() {
4  const navigate = useNavigate();
5
6  const goToExploration = () => {
7    navigate('/exploration');
8  };
9
10  const goBack = () => {
11    navigate(-1); // Reculer d'une page
12  };
13
14  const goForward = () => {
15    navigate(1);  // Avancer d'une page
16  };
17
18  const jumpToSpecificPoint = () => {
19    navigate(-3); // Reculer de trois pages
20  };
21
22  return (
23    <div className="mission-control">
24      <h1>Centre de contrôle de mission</h1>
25
26      <div className="navigation-panel">
27        <button onClick={goToExploration}>Démarrer l'exploration</button>
28        <button onClick={goBack}>Retour à l'emplacement précédent</button>
29        <button onClick={goForward}>Suivant</button>
30        <button onClick={jumpToSpecificPoint}>Retour au point de départ</button>
31      </div>
32    </div>
33  );
34}

Remplacer vs. ajouter à l'historique

Lors de l'utilisation de navigate, nous pouvons décider d'ajouter une nouvelle page à l'historique ou de remplacer la page actuelle :

1// Ajouter une nouvelle page à la pile d'historique (comportement par défaut)
2navigate('/new-location');
3
4// Remplacer la page actuelle dans l'historique
5navigate('/new-location', { replace: true });

Quand faut-il utiliser replace: true ?

  1. Redirections après connexion/déconnexion - nous ne voulons pas que l'utilisateur puisse revenir à la page de connexion après s'être connecté
  2. Formulaires multi-étapes - nous ne voulons pas que l'utilisateur puisse revenir aux étapes intermédiaires du processus
  3. Redirections depuis des pages inexistantes - lors d'une redirection d'un 404 vers la page d'accueil
  4. Lors d'une redirection - pour éviter les boucles dans l'historique du navigateur

Bloquer la navigation

Parfois, nous devons empêcher l'utilisateur de quitter une page, par exemple lorsqu'il a des modifications non enregistrées dans un formulaire. Imagine le configurateur de vaisseau : un astronaute a saisi le nom du vaisseau et choisi les moteurs, mais n'a pas encore enregistré la configuration, et il clique par mégarde sur un lien vers le hangar ou sur le bouton "Retour". Sans avertissement, tout ce travail est perdu.

React Router propose pour cela un hook intégré : useBlocker. Il est apparu dans la version 6.7 sous le nom unstable_useBlocker, il est stable depuis la version 6.19 et fonctionne aussi en v7 et v8. Tu connais déjà la condition grâce à la leçon sur les routes imbriquées : le hook ne fonctionne que dans un routeur de données (data router), c'est-à-dire quand tu passes tes routes à createBrowserRouter et que tu rends l'application avec <RouterProvider>. Dans un simple <BrowserRouter>, il lève l'erreur "useBlocker must be used within a data router".

1import { createBrowserRouter, RouterProvider } from 'react-router-dom';
2
3const router = createBrowserRouter([
4  { path: '/', element: <MissionControl /> },
5  { path: '/configurator', element: <SpaceshipConfigurator /> },
6]);
7
8function App() {
9  return <RouterProvider router={router} />;
10}

Ici, les routes sont décrites par un tableau d'objets, tandis que les composants de page comme MissionControl restent inchangés. En v8, tu importes createBrowserRouter et useBlocker depuis le paquet react-router, et RouterProvider depuis react-router/dom.

Passons au hook lui-même. useBlocker accepte un booléen ou une fonction. La fonction reçoit un objet avec les champs currentLocation, nextLocation et historyAction et renvoie true quand il faut arrêter la navigation. Le hook renvoie un objet blocker avec un champ state : 'unblocked' (rien n'est bloqué), 'blocked' (navigation suspendue) ou 'proceeding' (la navigation confirmée est en cours). Dans l'état 'blocked', le champ location indique où l'utilisateur voulait aller, et tu disposes de deux méthodes : proceed() reprend la navigation, et reset() l'annule en laissant l'utilisateur sur place.

1import { useState } from 'react';
2import { useBlocker } from 'react-router-dom';
3
4function SpaceshipConfigurator() {
5  const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
6  const [config, setConfig] = useState({});
7
8  // On bloque seulement s'il y a des modifications non enregistrées et si le chemin change
9  const blocker = useBlocker(
10    ({ currentLocation, nextLocation }) =>
11      hasUnsavedChanges && currentLocation.pathname !== nextLocation.pathname
12  );
13
14  return (
15    <div className="configurator">
16      <input
17        value={config.shipName ?? ''}
18        onChange={(e) => {
19          setConfig({ ...config, shipName: e.target.value });
20          setHasUnsavedChanges(true);
21        }}
22        placeholder="Nom du vaisseau"
23      />
24      <button onClick={() => setHasUnsavedChanges(false)}>Enregistrer</button>
25
26      {blocker.state === 'blocked' && (
27        <div className="warning-panel">
28          <p>Tu as des modifications non enregistrées. Es-tu sûr de vouloir quitter cette page ?</p>
29          <button onClick={() => blocker.proceed()}>Quitter la page</button>
30          <button onClick={() => blocker.reset()}>Rester</button>
31        </div>
32      )}
33    </div>
34  );
35}

Le blocage couvre les clics sur Link, les appels à navigate() ainsi que les boutons "Retour" et "Avancer" du navigateur, et après un clic sur "Enregistrer" la fonction renvoie false, donc la navigation redevient libre. Le hook n'affiche aucune fenêtre, c'est donc toi qui conçois le panneau d'avertissement, dans le style de ton application. Il existe aussi unstable_usePrompt, basé sur window.confirm, mais ses auteurs le laissent volontairement instable, car les navigateurs gèrent window.confirm de manières différentes.

useBlocker ne t'aidera pas, en revanche, quand l'utilisateur ferme l'onglet, recharge la page ou tape une autre adresse dans la barre d'adresse. Dans ce cas, le navigateur décharge toute la page et il ne reste que l'événement beforeunload. Nous allons le gérer dans un petit hook personnalisé :

1import { useEffect } from 'react';
2
3function useBeforeUnloadWarning(when) {
4  useEffect(() => {
5    if (!when) return;
6
7    const handleBeforeUnload = (e) => {
8      e.preventDefault();
9      e.returnValue = true; // pour les navigateurs plus anciens
10    };
11
12    window.addEventListener('beforeunload', handleBeforeUnload);
13    return () => {
14      window.removeEventListener('beforeunload', handleBeforeUnload);
15    };
16  }, [when]);
17}

Dans SpaceshipConfigurator, il suffit d'appeler useBeforeUnloadWarning(hasUnsavedChanges). L'écouteur n'existe que tant qu'il y a des modifications non enregistrées, et la fonction de nettoyage le retire dès qu'elles disparaissent. C'est ce que recommande MDN, car dans Firefox une page avec un écouteur beforeunload n'entre pas dans le bfcache, et y revenir prend plus de temps. Le navigateur affiche son propre message générique au lieu de ton texte, et seulement si l'utilisateur a déjà interagi avec la page.

Je te conseille de toujours combiner les deux mécanismes : useBlocker surveille la navigation à l'intérieur de l'application, et beforeunload la fermeture de l'onglet et le rechargement de la page.

Le hook useLocation

Le hook useLocation nous donne accès à l'objet location actuel, qui contient des informations sur l'URL actuelle :

1import { useLocation } from 'react-router-dom';
2
3function LocationTracker() {
4  const location = useLocation();
5
6  return (
7    <div>
8      <h2>Emplacement cosmique actuel</h2>
9      <p>Chemin : {location.pathname}</p>
10      <p>Requête : {location.search}</p>
11      <p>Hash : {location.hash}</p>
12      <p>Identifiant de clé : {location.key}</p>
13      <p>Méthode de navigation : {location.state?.navigationMethod || 'Standard'}</p>
14    </div>
15  );
16}

Le champ location.key est particulièrement utile - c'est un identifiant unique pour chaque entrée de l'historique. Nous pouvons l'utiliser pour détecter lorsque l'utilisateur revient sur une page qu'il a déjà visitée.

Suivre l'historique de navigation

Nous pouvons créer notre propre hook pour suivre l'historique de navigation :

1import { useState, useEffect } from 'react';
2import { useLocation } from 'react-router-dom';
3
4function useNavigationHistory(maxEntries = 10) {
5  const location = useLocation();
6  const [history, setHistory] = useState([]);
7
8  useEffect(() => {
9    setHistory(prev => {
10      // Ajouter l'emplacement actuel à l'historique
11      const newHistory = [...prev, {
12        pathname: location.pathname,
13        search: location.search,
14        hash: location.hash,
15        key: location.key,
16        timestamp: Date.now()
17      }];
18
19      // Limiter la taille de l'historique
20      return newHistory.slice(-maxEntries);
21    });
22  }, [location, maxEntries]);
23
24  return history;
25}
26
27// Utilisation :
28function NavigationLogger() {
29  const history = useNavigationHistory(5);
30
31  return (
32    <div className="navigation-log">
33      <h3>Journal de voyage</h3>
34      <ul>
35        {history.map((entry, index) => (
36          <li key={entry.key}>
37            {index === history.length - 1 ? '-> ' : ''}
38            {entry.pathname} (visité : {new Date(entry.timestamp).toLocaleTimeString()})
39          </li>
40        ))}
41      </ul>
42    </div>
43  );
44}

Modifier le comportement du bouton "Retour"

Parfois, nous voulons changer le comportement par défaut du bouton "Retour" du navigateur. Par exemple, si l'utilisateur est sur une page qui ne peut être visitée qu'après une connexion et qu'il clique sur "Retour", nous pouvons le rediriger vers un emplacement sûr :

1import { useEffect } from 'react';
2import { useNavigate, useLocation } from 'react-router-dom';
3
4function SecureZone() {
5  const navigate = useNavigate();
6  const location = useLocation();
7  const { isAuthenticated } = useAuth();
8
9  useEffect(() => {
10    // Vérifier si l'utilisateur est connecté à chaque changement d'emplacement
11    if (!isAuthenticated) {
12      navigate('/login', { replace: true });
13    }
14
15    // Comportement modifié du bouton "Retour"
16    const handlePopState = () => {
17      if (!isAuthenticated) {
18        // Si l'utilisateur n'est pas connecté et clique sur "Retour",
19        // le rediriger vers la page d'accueil au lieu de la page précédente
20        navigate('/', { replace: true });
21      }
22    };
23
24    window.addEventListener('popstate', handlePopState);
25
26    return () => {
27      window.removeEventListener('popstate', handlePopState);
28    };
29  }, [isAuthenticated, navigate, location]);
30
31  return (
32    <div className="secure-zone">
33      <h1>Zone sécurisée</h1>
34      <p>Bienvenue au centre de commandement. Tu as accès aux données classifiées.</p>
35    </div>
36  );
37}

Synchroniser l'historique avec l'état global

Dans les applications plus complexes, nous pouvons vouloir synchroniser l'historique du navigateur avec l'état global de l'application (par ex. Redux) :

1import { useEffect } from 'react';
2import { useLocation } from 'react-router-dom';
3import { useDispatch } from 'react-redux';
4
5function HistorySync() {
6  const location = useLocation();
7  const dispatch = useDispatch();
8
9  useEffect(() => {
10    // À chaque changement d'emplacement, nous mettons à jour l'état Redux
11    dispatch({
12      type: 'navigation/locationChanged',
13      payload: {
14        pathname: location.pathname,
15        search: location.search,
16        hash: location.hash,
17        state: location.state
18      }
19    });
20  }, [location, dispatch]);
21
22  return null; // Le composant ne rend rien
23}
24
25// Utilisation dans App :
26function App() {
27  return (
28    <Router>
29      <HistorySync />
30      {/* Reste de l'application */}
31    </Router>
32  );
33}

Analyser les paramètres d'URL

Nous avons souvent besoin d'analyser les paramètres de requête depuis l'URL :

1import { useLocation } from 'react-router-dom';
2
3function useParsedQuery() {
4  const location = useLocation();
5  const queryParams = new URLSearchParams(location.search);
6
7  return {
8    get: (param) => queryParams.get(param),
9    has: (param) => queryParams.has(param),
10    getAll: (param) => queryParams.getAll(param),
11    toString: () => queryParams.toString(),
12    entries: () => Array.from(queryParams.entries()).reduce((acc, [key, value]) => {
13      acc[key] = value;
14      return acc;
15    }, {})
16  };
17}
18
19// Utilisation :
20function SpaceshipFilter() {
21  const query = useParsedQuery();
22  const typeFilter = query.get('type'); // par ex. 'exploration'
23  const minCrewSize = query.get('minCrew'); // par ex. '3'
24
25  return (
26    <div>
27      <h2>Filtrage des vaisseaux</h2>
28      {typeFilter && <p>Filtrage par type : {typeFilter}</p>}
29      {minCrewSize && <p>Taille minimale de l'équipage : {minCrewSize}</p>}
30
31      {/* Reste du composant */}
32    </div>
33  );
34}

Gestion de la pile et de la profondeur de l'historique

Parfois, nous voulons vérifier à quelle profondeur l'utilisateur se trouve dans l'historique du navigateur, ou effectuer une action après avoir atteint une certaine profondeur :

1import { useState, useEffect } from 'react';
2
3function useHistoryDepth() {
4  const [depth, setDepth] = useState(0);
5
6  useEffect(() => {
7    // Définir la profondeur initiale
8    setDepth(window.history.length);
9
10    // Écouter les changements
11    const handleNavigation = () => {
12      setDepth(window.history.length);
13    };
14
15    window.addEventListener('popstate', handleNavigation);
16
17    return () => {
18      window.removeEventListener('popstate', handleNavigation);
19    };
20  }, []);
21
22  return depth;
23}
24
25// Utilisation :
26function NavigationDepthIndicator() {
27  const depth = useHistoryDepth();
28
29  return (
30    <div className="depth-indicator">
31      <p>Profondeur de l'historique : {depth}</p>
32      {depth > 10 && (
33        <button onClick={() => window.history.go(-5)}>
34          Reculer de 5 pages
35        </button>
36      )}
37    </div>
38  );
39}

Scénarios avancés

1. Restauration du défilement

Le comportement standard du navigateur est de restaurer la position de défilement lors de la navigation en arrière. Dans les applications SPA, nous devons souvent l'implémenter manuellement :

1import { useEffect, useRef } from 'react';
2import { useLocation } from 'react-router-dom';
3
4function useScrollRestoration() {
5  const location = useLocation();
6  const scrollPositions = useRef({});
7
8  // Enregistrer la position de défilement au changement d'emplacement
9  useEffect(() => {
10    const saveScrollPosition = (key) => {
11      scrollPositions.current[key] = window.scrollY;
12    };
13
14    // Enregistrer la position actuelle lorsque l'utilisateur quitte la page
15    return () => {
16      saveScrollPosition(location.key);
17    };
18  }, [location]);
19
20  // Restaurer la position de défilement lorsque l'utilisateur revient sur une page
21  useEffect(() => {
22    if (scrollPositions.current[location.key] !== undefined) {
23      // Restaurer la position enregistrée
24      window.scrollTo(0, scrollPositions.current[location.key]);
25    } else {
26      // Nouvelle page, défiler vers le haut
27      window.scrollTo(0, 0);
28    }
29  }, [location]);
30}
31
32// Utilisation :
33function ScrollRestoringPage() {
34  useScrollRestoration();
35
36  return (
37    <div className="long-content-page">
38      {/* Beaucoup de contenu provoquant du défilement */}
39    </div>
40  );
41}

2. Détecter les changements de paramètres

Parfois, nous avons besoin de savoir si des paramètres d'URL spécifiques ont changé :

1import { useEffect, useRef } from 'react';
2import { useLocation } from 'react-router-dom';
3
4function useQueryParamChange(paramName) {
5  const location = useLocation();
6  const previousParam = useRef(null);
7
8  // Obtenir la valeur actuelle du paramètre
9  const query = new URLSearchParams(location.search);
10  const currentValue = query.get(paramName);
11
12  // Vérifier si la valeur a changé
13  const hasChanged = previousParam.current !== currentValue;
14
15  // Mettre à jour la ref pour le prochain rendu
16  useEffect(() => {
17    previousParam.current = currentValue;
18  }, [currentValue]);
19
20  return {
21    value: currentValue,
22    hasChanged,
23    previousValue: previousParam.current
24  };
25}
26
27// Utilisation :
28function GalaxyFilter() {
29  const { value: galaxyType, hasChanged } = useQueryParamChange('galaxy');
30
31  useEffect(() => {
32    if (hasChanged) {
33      // Effectuer une action lors du changement de type de galaxie
34      console.log(`Filtre changé en : ${galaxyType}`);
35      // par ex. récupérer de nouvelles données
36    }
37  }, [galaxyType, hasChanged]);
38
39  return (
40    <div>
41      {/* Composants de filtrage */}
42    </div>
43  );
44}

Résumé

La gestion consciente de l'historique du navigateur est comme la tenue d'un journal de voyage cosmique - elle nous permet de :

  1. Naviguer dans le temps - l'utilisateur peut facilement revenir aux emplacements visités
  2. Contrôler le flux - nous pouvons influencer la façon dont l'utilisateur se déplace dans l'application
  3. Préserver le contexte - les données d'état peuvent être transférées entre les emplacements
  4. Optimiser l'expérience - restauration de l'état, de la position de défilement, etc.

Une gestion bien conçue de l'historique du navigateur rend le voyage à travers notre application intuitif et agréable, tout comme une mission spatiale bien planifiée.

Souviens-toi que l'historique du navigateur n'est pas seulement un mécanisme de navigation, mais aussi un élément important de l'expérience utilisateur. Les utilisateurs s'attendent à ce que les boutons "Retour" et "Suivant" fonctionnent de manière intuitive, et ton application devrait respecter cela.

Code de cette leçon : App.jsx
1import React, { Suspense, lazy, useState } from 'react';
2import { BrowserRouter, Routes, Route, Link } from 'react-router-dom';
3
4// Composants chargés de façon différée
5const Dashboard = lazy(() => {
6  // Simulation d'un délai de chargement
7  return new Promise(resolve => {
8    setTimeout(() => {
9      resolve({
10        default: () => (
11          <div className="page">
12            <h1>Dashboard</h1>
13            <p>Ce composant a été chargé de façon différée !</p>
14            <div className="stats">
15              <div className="stat">Utilisateurs : 1 234</div>
16              <div className="stat">Sessions : 5 678</div>
17              <div className="stat">Revenu : 9 999 crédits</div>
18            </div>
19          </div>
20        )
21      });
22    }, 1500);
23  });
24});
25
26const Analytics = lazy(() => {
27  return new Promise(resolve => {
28    setTimeout(() => {
29      resolve({
30        default: () => (
31          <div className="page">
32            <h1>Analytics</h1>
33            <p>Statistiques avancées chargées de façon différée.</p>
34            <div className="chart">
35              <div className="bar" style={{height: '60%'}}></div>
36              <div className="bar" style={{height: '80%'}}></div>
37              <div className="bar" style={{height: '45%'}}></div>
38              <div className="bar" style={{height: '90%'}}></div>
39              <div className="bar" style={{height: '70%'}}></div>
40            </div>
41          </div>
42        )
43      });
44    }, 2000);
45  });
46});
47
48const Settings = lazy(() => {
49  return new Promise(resolve => {
50    setTimeout(() => {
51      resolve({
52        default: () => (
53          <div className="page">
54            <h1>Paramètres</h1>
55            <p>Configuration du système.</p>
56            <div className="settings-list">
57              <div className="setting">Mode sombre : Activé</div>
58              <div className="setting">Notifications : Activées</div>
59              <div className="setting">Langue : Français</div>
60            </div>
61          </div>
62        )
63      });
64    }, 1000);
65  });
66});
67
68// Composant fallback de chargement
69function LoadingSpinner() {
70  return (
71    <div className="loading">
72      <div className="spinner"></div>
73      <p>Chargement du module...</p>
74    </div>
75  );
76}
77
78// Page d'accueil (non différée)
79function HomePage() {
80  return (
81    <div className="page">
82      <h1>Page d'accueil</h1>
83      <p>Cette page est chargée immédiatement.</p>
84      <p className="info">Clique sur les autres liens pour voir le chargement différé en action !</p>
85    </div>
86  );
87}
88
89function App() {
90  return (
91    <BrowserRouter>
92      <div className="app">
93        <nav>
94          <Link to="/">Accueil (instantané)</Link>
95          <Link to="/dashboard">Dashboard (1.5s)</Link>
96          <Link to="/analytics">Analytics (2s)</Link>
97          <Link to="/settings">Paramètres (1s)</Link>
98        </nav>
99
100        <Suspense fallback={<LoadingSpinner />}>
101          <Routes>
102            <Route path="/" element={<HomePage />} />
103            <Route path="/dashboard" element={<Dashboard />} />
104            <Route path="/analytics" element={<Analytics />} />
105            <Route path="/settings" element={<Settings />} />
106          </Routes>
107        </Suspense>
108      </div>
109    </BrowserRouter>
110  );
111}
112
113export default App;

Tu as repéré une erreur dans cette leçon ?

Teste tes connaissances

Réponds aux questions de cette leçon. Choisis une réponse pour voir tout de suite si elle est correcte.

  1. 1. Quelles informations le hook useLocation fournit-il ?

  2. 2. Que fait l'appel navigate(-1) dans React Router ?

Voici 2 des 3 questions de cette leçon. Réponds aux autres dans le jeu.

Tâches pratiques dans le jeu

  • Éditeur de code

    Suivi de l'emplacement

  • Tri horizontal

    Ordonne les composants d'une URL dans l'ordre où ils apparaissent dans une adresse :

Articles utiles