f) Le routage des écrans

Introduction au routing

Lorsqu'on parle d'une IHM, nous souhaitons généralement afficher différents écrans en réagissant aux actions des utilisateurs.

Le routage (ou routing en anglais) est ce qui rend possible l'affichage de différents écrans.

Dans les applications "old school" de type Multi-Page-Application (MPA), pour changer de page, il faudrait :

  • Faire un clic sur un élément qui permet de faire une requête HTTP au serveur pour demander un page.
  • Le browser fait la requête HTTP de type GET au serveur.
  • L'application serveur (le backend) s'occupe du rendu de l'HTML et le renvoie au browser (le client).
  • Le browser affiche cette page.

Dans les applications que nous allons développer dans ce cours, l'architecture est complètement différente. Nous développons des Single-Page-Applications (SPA), pour changer de page :

  • Il faut cliquer sur un élément de la page permettant la navigation.
  • Le JS exécuté dans le browser s'occupe de créer l'illusion que l'on change de page en faisant lui-même le rendu de la nouvelle page.
  • Si des données sont nécessaires pour afficher la page, le JS exécuté dans le browser s'occupera de faire un "fetch" de celles-ci au format JSON (RESTful API) et générera dynamiquement l'HTML nécessaire.

Ainsi, dans une SPA, une seule page est chargée la toute première fois que l'on accède au serveur : c'est index.html et tous les assets associés (scripts JS, les images, CSS, sons...). Par la suite, on va utiliser un router (qui se trouvera dans un script JS) qui s'occupera de faire du "Client Side Rendering" (rendu côté client de l'HTML).

Dans nos applications Vite + React + TS, c'est le code transpilé du TS vers le JS qui s'occupera :

  • d'accéder à un container présent dans la représentation mémoire des éléments HTML de la page (par exemple la div#root).
  • de mettre à jour la représentation mémoire de ce container avec les éléments HTML attendu pour la page demandé.

Ensuite, le browser n'aura plus qu'à redessiner la page sur base de la nouvelle représentation mémoire des éléments HTML de la page.

Notons que dans une MPA, on parle de "Server Side Rendering", car c'est le backend qui est responsable de la génération de l'HTML ; cela se fait souvent à l'aide d'un moteur de templating pour générer des views (par exemple via Handlebars qui permet de générer des views dans une application Node.js).

Navigation basique entre pages

A notre stade actuel de connaissances, nous pourrions très facilement organiser la navigation entre plusieurs page, simplement à l'aide d'une variable d'état et des gestionnaires de clics.

Pour ce tutoriel, nous allons partir d'une base de code minimaliste. Veuillez donc créer un nouveau projet Vite + React + TS nommé routing.
Vous ne vous souvenez plus comment faire ? Voici la commande :

bash
npm create vite@latest routing -- --template react-swc-ts

Veuillez remplacer le code de App :

tsx
import { useState } from "react";
const HomePage = () => <div>Home Page</div>;
const AboutPage = () => <div>About Page</div>;
const ContactPage = () => <div>Contact Page</div>;
const App = () => {
const [currentPage, setCurrentPage] = useState("Home");
const navigateTo = (page: string) => {
setCurrentPage(page);
};
const renderPage = () => {
switch (currentPage) {
case "Home":
return <HomePage />;
case "About":
return <AboutPage />;
case "Contact":
return <ContactPage />;
default:
return <HomePage />;
}
};
return (
<div>
<nav>
<button onClick={() => navigateTo("Home")}>Home</button>
<button onClick={() => navigateTo("About")}>About</button>
<button onClick={() => navigateTo("Contact")}>Contact</button>
</nav>
{renderPage()}
</div>
);
};
export default App;
export { HomePage, AboutPage, ContactPage };

Nous avons donc ici défini 3 composants React qui représentent 3 pages, et une fonction qui permet, lors d'un clic, d'afficher la page associée au bouton.

Veuillez exécuter l'application.

Tout fonctionne bien !

💭 Mais qu'est-ce qui nous manque ?

Voici ce qui fait défaut :

  • Si nous faisons un refresh de la page, nous perdons la page en cours. Par exemple, si nous sommes sur ContactPage, nous serons redirigé vers HomePage.
  • Nous n'avons pas d'historique des pages visitées, nous ne pouvons pas revenir en arrière, ni en avant dans le temps.
  • Nous n'avons pas une URL spécifique pour chaque écran.

Nous allons donc mettre en place en routeur afin de bénéficier de ces fonctions manquantes.

Mise en place de React Router

Introduction

React Router est une librairie qui fournit une belle solution pour gérer la navigation dans une application React.

Sa documentation est disponible ici : https://reactrouter.com/en/main

Installation de la librairie

Dans un premier temps, il faut donc installer la librairie :

sh
npm i react-router-dom

Mise en place de routes basiques

Dans un premier temps, nous allons voir comment créer la configuration la plus simple d'un router. Veuillez mettre à jour /src/main.tsx :

tsx
1
import React from "react";
2
import ReactDOM from "react-dom/client";
3
import { AboutPage, ContactPage, HomePage } from "./App.tsx";
4
import "./index.css";
5
import { RouterProvider, createBrowserRouter } from "react-router-dom";
6
7
const router = createBrowserRouter([
8
{
9
path: "/",
10
element: <HomePage />,
11
},
12
{
13
path: "/about",
14
element: <AboutPage />,
15
},
16
{
17
path: "/contact",
18
element: <ContactPage />,
19
},
20
]);
21
22
ReactDOM.createRoot(document.getElementById("root")!).render(
23
<React.StrictMode>
24
<RouterProvider router={router}/>
25
</React.StrictMode>
26
);

Ici, nous avons donc createBrowserRouter qui crée une configuration pour nos 3 routes, et chaque route va afficher une page.

Ensuite, nous utilisons le RouterProvider pour fournir la configuration du routeur à l'ensemble de l'application et pour permettre aux composants de l'application d'accéder au contexte du router.

Pour cette première utilisation du router, nous n'allons pas tenter d'optimiser la navigation. Nous allons donc intégrer à chaque page une NavBar.
Chaque élément de navigation va utiliser <Link> comme composant pour faire le lien avec les routes que nous avons configurées.

Veuillez mettre à jour le composant App :

tsx
1
import { Link } from "react-router-dom";
2
3
const NavBar = () => (
4
<nav>
5
<Link to="/">Home</Link>
6
<Link to="/about">About</Link>
7
<Link to="/contact">Contact</Link>
8
</nav>
9
);
10
11
const HomePage = () => (
12
<div>
13
<NavBar />
14
<p>Home Page</p>
15
</div>
16
);
17
const AboutPage = () => (
18
<div>
19
<NavBar />
20
<p>About Page</p>
21
</div>
22
);
23
const ContactPage = () => (
24
<div>
25
<NavBar />
26
<p>Contact Page</p>
27
</div>
28
);
29
30
const App = () => {
31
return <div></div>;
32
};
33
34
export default App;
35
export { HomePage, AboutPage, ContactPage };

Veuillez lancer votre application et vérifier que tout fonctionne. Lorsque vous cliquez sur un lien, le router détecte qu'il y a eu un changement d'état, et element dans la configuration du router est rappelé pour la route associée, amenant au render du composant.

Utilisation de useNavigation

Si l'on souhaite se rapprocher du design initial, nous pouvons utiliser le hook useNavigation qui offre une fonction pour naviguer vers une nouvelle route.

Veuillez mettre App à jour :

tsx
1
import { useNavigate } from "react-router-dom";
2
3
const NavBar = () => {
4
const navigate = useNavigate();
5
return (
6
<nav>
7
<button onClick={() => navigate("/")}>Home</button>
8
<button onClick={() => navigate("/about")}>About</button>
9
<button onClick={() => navigate("/contact")}> Contact</button>
10
</nav>
11
);
12
};
13
14
const HomePage = () => (
15
<div>
16
<NavBar />
17
<p>Home Page</p>
18
</div>
19
);
20
const AboutPage = () => (
21
<div>
22
<NavBar />
23
<p>About Page</p>
24
</div>
25
);
26
const ContactPage = () => (
27
<div>
28
<NavBar />
29
<p>Contact Page</p>
30
</div>
31
);
32
33
const App = () => {
34
return <div></div>;
35
};
36
37
export default App;
38
export { HomePage, AboutPage, ContactPage };

Voila, nous avons un design qui ressemble au design initial.
N'hésitez pas à tester le router :

  • Faites un reload quand vous êtes dans la ContactPage pour voir que vous y restez bien.
  • Naviguez sur plusieurs pages, puis utiliser les fonctionnalités "Back" and "Forward" pour voyager dans l'historique de votre Browser.
  • Vérifiez bien que l'URL dans le browser correspond bien à la page demandée.

💭 Est-ce qu'il n'y a pas quelque chose qui vous dérange dans la solution actuelle du layout de nos pages ?

Actuellement, nous intégrons une NavBar dans chaque page. Cela signifie qu'à chaque navigation d'une page à une autre, c'est l'entièreté de la page qui doit être rendue, y compris les éléments de la Navbar, qui pourtant ne changent pas !

Il serait intéressant de pouvoir définir un layout de tout ce qui devrait être rendu qu'une seule fois dans notre page, comme par exemple une Navbar.

Pour ce faire, nous allons voir les "nested routes".

Nested routes

Il est possible de définir une route parent, ça serait la route "racine" ici, pour afficher le squelette de nos pages.

Ensuite, nous afficherons des routes "enfants" au sein de la route "parent". Pour indiquer où les routes "enfants" devront s'afficher chez le "parent", nous utiliserons un <Outlet>.

Voici comment définir la route / pour le squelette de l'application qui se trouvera dans App, puis les 3 routes "enfants" pour les 3 pages (veuillez mettre à jour le router au sein de main.tsx) :

tsx
1
const router = createBrowserRouter([
2
{
3
path: "/",
4
element: <App />,
5
children: [
6
{
7
path: "",
8
element: <HomePage />,
9
},
10
{
11
path: "about",
12
element: <AboutPage />,
13
},
14
{
15
path: "contact",
16
element: <ContactPage />,
17
},
18
],
19
},
20
]);

Il ne nous reste plus qu'à mettre à jour App pour intégrer le Outlet et pour enlever l'appel de chaque page à la NavBar :

tsx
1
import { Outlet, useNavigate } from "react-router-dom";
2
3
const NavBar = () => {
4
const navigate = useNavigate();
5
6
return (
7
<nav>
8
<button onClick={() => navigate("/")}>Home</button>
9
<button onClick={() => navigate("/about")}>About</button>
10
<button onClick={() => navigate("/contact")}> Contact</button>
11
</nav>
12
);
13
};
14
15
const HomePage = () => <p>Home Page</p>;
16
const AboutPage = () => <p>About Page</p>;
17
const ContactPage = () => <p>Contact Page</p>;
18
19
const App = () => (
20
<div>
21
<NavBar />
22
<Outlet />
23
</div>
24
);
25
26
export default App;
27
export { HomePage, AboutPage, ContactPage };

Nous avons là un code bien propre, et une navigation parfaitement fonctionnelle !
Il est à noter que le code serait encore plus simple si nous utilisions le composant Link de la librairie (il suffirait de le styler pour qu'il ressemble à un bouton).

URL dynamiques

Parfois, il est intéressant qu'une même composant soit appelé sur toute une famille de routes.

Par exemple, dans le composant AboutPage, nous souhaitons afficher une liste d'utilisateurs. Lorsque nous cliquons sur une utilisatrice ou un utilisateur, nous souhaitons faire appel à un nouveau composant UserPage qui permettra d'afficher sa page associée avec comme url : /users/:userId.

Veuillez mettre à jour le composant App pour créer la UserPage et mettre à jour AboutPage :

tsx
1
import { Link, Outlet, useMatch, useNavigate } from "react-router-dom";
2
3
const NavBar = () => {
4
const navigate = useNavigate();
5
6
return (
7
<nav>
8
<button onClick={() => navigate("/")}>Home</button>
9
<button onClick={() => navigate("/about")}>About</button>
10
<button onClick={() => navigate("/contact")}> Contact</button>
11
</nav>
12
);
13
};
14
15
const HomePage = () => <p>Home Page</p>;
16
const AboutPage = () => (
17
<div>
18
<h1>About Page</h1>
19
<h2>Authors:</h2>
20
{users.map((user) => (
21
<Link key={user.id} to={`/users/${user.id}`} style={{ display: "block" }}>
22
{user.name}
23
</Link>
24
))}
25
</div>
26
);
27
const ContactPage = () => <p>Contact Page</p>;
28
29
const users: User[] = [
30
{
31
id: 1,
32
name: "John Doe",
33
email: "john.doe@example.com",
34
phone: "123-456-7890",
35
},
36
{
37
id: 2,
38
name: "Jane Smith",
39
email: "jane.smith@example.com",
40
phone: "234-567-8901",
41
},
42
{
43
id: 3,
44
name: "James Brown",
45
email: "james.brown@example.com",
46
phone: "345-678-9012",
47
},
48
];
49
50
const UserPage = () => {
51
const match = useMatch("/users/:userId");
52
const userId = match?.params.userId;
53
if (!userId) return <p>User not found</p>;
54
55
const user = users.find((user) => user.id.toString() === userId);
56
if (!user) return <p>User not found</p>;
57
58
return (
59
<div>
60
<h2>{user.name}</h2>
61
<p>Email: {user.email}</p>
62
<p>Phone: {user.phone}</p>
63
</div>
64
);
65
};
66
67
const App = () => (
68
<div>
69
<NavBar />
70
<Outlet />
71
</div>
72
);
73
74
interface User {
75
id: number;
76
name: string;
77
email: string;
78
phone: string;
79
}
80
81
export default App;
82
export { HomePage, AboutPage, ContactPage, UserPage };

Le composant AboutPage contient des Link qui pointent vers des URL qui sont /users/1 pour le premier user, /users/2 pour le user qui a l'id 2...

Pour récupérer cette id dans la page des utilisateurs (le composant UserPage), nous utilisons le hook useMatch("/users/:userId") pour indiquer le segment dynamique de l'URL par une variable qui sera accessible via match.params.userId.

Pour que tout cela fonctionne, il ne reste plus qu'à configurer le router pour cette route dynamique. Veuillez mettre à jour la configuration du router dans /src/main.tsx :

tsx
1
const router = createBrowserRouter([
2
{
3
path: "/",
4
element: <App />,
5
children: [
6
{
7
path: "",
8
element: <HomePage />,
9
},
10
{
11
path: "about",
12
element: <AboutPage />,
13
},
14
{
15
path: "contact",
16
element: <ContactPage />,
17
},
18
{
19
path: "users/:userId",
20
element: <UserPage />,
21
}
22
],
23
},
24
]);

Veuillez vérifier que tout fonctionne bien, que vous pouvez afficher la page de James Brown.

💭 Il est à noter que si nous n'avions pas voulu créer une nouvelle page mais plutôt afficher le détail d'un utilisateur dans le composant AboutPage, nous aurions pour créer une route "enfant" de /about (en utilisant un Outlet dans AboutPage).

Si nécessaire, vous pouvez trouver le code associé à ce tutoriel ici : routing.

Exercice 2.10 : React Router de base

Dans vos exercices précédents, vous avez créer une page pour afficher les films des cinémas UGC (/exercises/2.6). Vous avez aussi créé une page pour afficher vos films préférés dans un autres exercice (/exercises/2.7).

Nous vous proposons ici de créer une nouvelle application iMovies qui s'occupera d'intégrer ces contenus et de mettre en place la navigation.

Veuillez partir d'une copie de l'exercice (/exercises/2.7) et y intégrer le code de l'exercice (/exercises/2.6) dans un nouveau projet nommé exercises/2.10-11-12 pour afficher :

  • Un header & un footer pour chaque page
  • Une navbar (à vous de choisir où la mettre)
  • Une nouvelle HomePage qui donne quelques explications sur l'application iMovies (pas besoin de la peaufiner, l'idée est juste de travailler la mise en place de la navigation).
  • Une CinemaPage qui reprend simplement le contenu de l'exercice (/exercises/2.6).
  • Une MovieListPage qui reprend la liste de vos films selon le design de l'exercise (/exercises/2.7).

Une fois tout fonctionnel, veuillez faire un commit avec le message suivant : new:ex2.10.

Comment gérer l'état avec React Router ?

Il est possible que vous ayez remarqué, dans le code du router du tutoriel précédent, qu'il semble compliqué, voire impossible, de faire passer des variables d'états & des fonctions pour mettre à jour cet état, entre routes...

Pour la pizzeria, l'IHM que nous avons développée s'est terminée avec le code du tutoriel ui-library. Néanmoins, pour la suite du cours, nous ne souhaitons pas vous imposer d'utiliser des composants de Material UI. Dès lors, nous avons restructuré le code pour avoir quelque chose de propre, qui contient :

  • que du CSS sans composants MUI;
  • deux pages : HomePage & AddPizzaPage ;
  • un router et une NavBar pour assurer la navigation. Cette nouvelle version de l'App se trouve dans le projet routing-starter.

Pour ce nouveau tutoriel, veuillez créer un nouveau projet routing-state sur base d'un copier/coller du projet routing-starterrouting-starter. Attention, il est normal que votre projet ne s'exécute pas car il manque la gestion de l'état.
N'hésitez pas à utiliser ce site pour télécharger le code du dossier routing-starter : https://download-directory.github.io/

Veuillez vous assurer que vous comprenez le code associé au routage des pages : main.tsx, App et HomePage et AddPizzaPage sont à bien analyser.

Pour gérer l'état entre siblings (deux pages ici, l'équivalent de deux routes), nous avons appris précédemment qu'il fallait :

  • déclarer l'état et des fonctions pour mettre à jour cet état au niveau du parent ;
  • passer cet état & fonctions aux enfants (les pages ici) qui vont devoir l'utiliser.

Or ici, la relation "parent/enfant" est compliquée, car :

  • il y a un composant <App> qui contient tout le squelette de l'application, pour les 2 pages de l'application ;
  • il y a un composant <Outlet> qui s'occupe d'appeler les composants "enfants" (les pages) en fonction de la route.

Ainsi, il n'est pas vraiment possible de classiquement faire un "drill" des variables d'état et des fonctions. On ne peut pas passer les variables d'état, ainsi que les fonctions pour mettre à jour cet état, de App vers HomePage et AddMoviePage.

Il existe plusieurs façon de gérer de manière élégante l'état de l'application. Ici, nous allons voir ce que React Router met à notre disposition sans devoir utiliser une nouvelle librairie.

Utilisation d'un OutletContext

Dans une route "parent", nous allons définir un contexte à l'aide du composant Outlet. Ce contexte peut être n'importe quelle donnée ou fonction que nous souhaitons partager avec les routes "enfants".

Dans une route "enfant", nous pouvons accéder au contexte en utilisant le hook useOutletContext.

Commençons par mettre à jour App en y ajoutant la définition et le passage du contexte aux routes "enfants" :

tsx
1
const App = () => {
2
const [actionToBePerformed, setActionToBePerformed] = useState(false);
3
const [pizzas, setPizzas] = useState(defaultPizzas);
4
5
const addPizza = (newPizza: NewPizza) => {
6
const pizzaAdded = { ...newPizza, id: nextPizzaId(pizzas) };
7
setPizzas([...pizzas, pizzaAdded]);
8
};
9
10
const handleHeaderClick = () => {
11
setActionToBePerformed(true);
12
};
13
14
const clearActionToBePerformed = () => {
15
setActionToBePerformed(false);
16
};
17
18
const fullPizzaContext: PizzeriaContext = {
19
addPizza,
20
pizzas,
21
setPizzas,
22
actionToBePerformed,
23
setActionToBePerformed,
24
clearActionToBePerformed,
25
drinks,
26
};
27
28
return (
29
<div className="page">
30
<Header
31
title="We love Pizza"
32
version={0 + 1}
33
handleHeaderClick={handleHeaderClick}
34
/>
35
<main>
36
<NavBar />
37
<Outlet context={fullPizzaContext} />
38
</main>
39
<Footer />
40
</div>
41
);
42
};

Pour que TS soit OK au niveau des types, nous avons défini un nouveau type dans /src/types.ts :

ts
interface PizzeriaContext {
pizzas: Pizza[];
setPizzas: (pizzas: Pizza[]) => void;
actionToBePerformed: boolean;
setActionToBePerformed: (actionToBePerformed: boolean) => void;
clearActionToBePerformed: () => void;
drinks: Drink[];
addPizza: (newPizza: NewPizza) => void;
}
export type { Pizza, NewPizza, Drink, PizzeriaContext };

Veuillez importer ce nouveau type dans App.

Maintenant, nous souhaitons mettre à jour HomePage pour récupérer, via le hook useOutletContext, le PizzeriaContext :

tsx
1
const HomePage = () => {
2
const {
3
actionToBePerformed,
4
clearActionToBePerformed,
5
pizzas,
6
drinks,
7
}: PizzeriaContext = useOutletContext();
8
9
return (
10
<>
11
<h1>Ma Pizzeria</h1>
12
<p>
13
Parce que nous aimons le JS/TS, vous pouvez cliquer sur le header pour
14
démarrer / stopper la musique ; )
15
</p>
16
<AudioPlayer
17
sound={sound}
18
actionToBePerformed={actionToBePerformed}
19
clearActionToBePerformed={clearActionToBePerformed}
20
/>
21
22
<PizzaMenu pizzas={pizzas} />
23
24
<DrinkMenu title="Nos boissons" drinks={drinks} />
25
</>
26
);
27
};

Puis, nous souhaitons aussi mettre à jour AddMoviePage pour récupérer la fonction addPizza du contexte :

tsx
const AddPizzaPage = () => {
const { addPizza }: PizzeriaContext = useOutletContext();

N'oubliez pas de faire l'import de useOutletContext et du type PizzeriaContext. Une fois les changements effectués, vous devriez avoir une application pleinement fonctionnelle, avec un routing moderne et une gestion élégante de l'état.

Si nécessaire, vous pouvez trouver le code associé à ce tutoriel ici : routing-state.

Exercice 2.11 : État avec un router

Veuillez continuer votre exercice précédent dans le projet existant et nommé exercises/2.10-11-12 en y intégrant une AddMoviePage qui permette d'ajouter un film à la liste des films. Une fois un film ajouté, l'utilisateur est automatiquement redirigé vers la MovieListPage.

Une fois tout fonctionnel, veuillez faire un commit avec le message suivant : new:ex2.11.

Exercice 2.12 : Routes dynamiques

Veuillez continuer l'exercice précédent dans le projet existant et nommé exercises/2.10-11-12.

Nous vous demandons :

  • De mettre à jour la HomePage afin qu'elle affiche une liste reprenant uniquement les titres de vos films favoris (sans d'autres infos associées aux films telles que la description...).
  • Il doit être possible de pouvoir cliquer sur le titre d'un de vos films favoris et de naviguer vers une nouvelle MoviePage qui affichera toutes les infos de ce film-ci. Pour ce faire, vous devez ajouter un id à vos films, et cette id doit être visible dans l'URL quand les utilisateurs cliquent sur un titre donné dans la HomePage.

Une fois tout fonctionnel, veuillez faire un commit avec le message suivant : new:ex2.12.