Chapitre 415 minIntermédiaire
Navigation avec Expo Router : pile, onglets et paramètres
Par Josselyn C., prof de BTS SIO. Mis à jour le .
À la fin de ce chapitre, tu sauras
- associer chaque fichier de src/app à son écran et à son adresse ;
- construire une navigation par pile et par onglets ;
- passer et valider des paramètres entre écrans ;
- protéger les écrans réservés aux utilisateurs connectés ;
- ouvrir un écran par un lien profond sans créer de faille.
4 exercices corrigés1 schéma
Expo Router est le système de navigation d'Expo : chaque fichier du dossier src/app devient un écran, avec sa propre adresse, et des fichiers _layout.tsx disent comment ces écrans s'enchaînent, en pile ou en onglets. C'est le même principe que le routage par fichiers de Next.js. Ce chapitre construit la navigation complète d'AppliFrais : l'écran de connexion, les onglets « Mes fiches » et « Profil », le détail d'une fiche et la fenêtre d'ajout d'un frais, avec la protection des écrans réservés aux utilisateurs connectés.
Les façons de naviguer sur un téléphone
Sur un téléphone, on ne navigue pas comme dans un navigateur. Quatre modèles reviennent dans presque toutes les applications :
| Modèle | Comportement | Dans AppliFrais |
|---|---|---|
| Pile (stack) | Chaque nouvel écran se pose sur le précédent ; le bouton retour le retire | Liste des fiches → détail d'une fiche |
| Onglets (tabs) | Une barre en bas de l'écran ; chaque onglet garde sa propre position | « Mes fiches » et « Profil » |
| Modale | Un écran qui monte par-dessus le reste, pour une tâche courte | « Nouveau frais » |
| Tiroir (drawer) | Un menu qui sort du bord gauche | Pas utilisé |
Sur Android, le bouton ou le geste retour du système doit toujours faire quelque chose de logique : revenir à l'écran précédent de la pile, fermer la modale, ou quitter l'application depuis le premier écran. Expo Router le gère pour toi tant que tu utilises ses navigateurs.
Une route par fichier
Dans src/app, le chemin d'un fichier donne l'adresse de l'écran :
| Fichier | Adresse (route) | Écran |
|---|---|---|
src/app/connexion.tsx | /connexion | Connexion |
src/app/(onglets)/index.tsx | / | Mes fiches |
src/app/(onglets)/profil.tsx | /profil | Profil |
src/app/fiche/[id]/index.tsx | /fiche/12 | Détail de la fiche 12 |
src/app/fiche/[id]/frais.tsx | /fiche/12/frais | Nouveau frais sur la fiche 12 |
src/app/_layout.tsx | aucune | Le navigateur racine |
src/app/(onglets)/_layout.tsx | aucune | La barre d'onglets |
Trois conventions à retenir :
index.tsxest l'écran par défaut de son dossier, commeindex.htmlsur un serveur web ;- un nom entre crochets (
[id]) est un segment dynamique : il accepte n'importe quelle valeur, que l'écran lit comme un paramètre ; - un dossier entre parenthèses (
(onglets)) est un groupe : il organise les fichiers et porte un layout, mais n'apparaît pas dans l'adresse.
Le layout racine et la pile
Le fichier src/app/_layout.tsx définit le navigateur racine. Une pile (Stack) suffit pour commencer :
import { Stack } from 'expo-router';
export default function RacineLayout() {
return (
<Stack>
<Stack.Screen name="(onglets)" options={{ headerShown: false }} />
<Stack.Screen name="fiche/[id]/index" options={{ title: 'Fiche de frais' }} />
<Stack.Screen name="fiche/[id]/frais" options={{ title: 'Nouveau frais', presentation: 'modal' }} />
<Stack.Screen name="connexion" options={{ headerShown: false }} />
</Stack>
);
}
Chaque Stack.Screen désigne un fichier par son chemin, sans extension, et règle ses options : le titre de l'en-tête, son affichage, la présentation en modale. Un écran qui n'est pas déclaré existe quand même, avec les options par défaut. L'en-tête de la pile affiche automatiquement le titre et le bouton retour.
Les onglets
Le groupe (onglets) a son propre layout, src/app/(onglets)/_layout.tsx, qui affiche une barre d'onglets :
import { Tabs } from 'expo-router/js-tabs';
export default function OngletsLayout() {
return (
<Tabs screenOptions={{ tabBarActiveTintColor: '#1d4ed8' }}>
<Tabs.Screen name="index" options={{ title: 'Mes fiches' }} />
<Tabs.Screen name="profil" options={{ title: 'Profil' }} />
</Tabs>
);
}
Depuis le SDK 58, un navigateur à onglets n'affiche que les écrans déclarés dans son layout : un fichier oublié dans la liste n'a pas d'onglet. Pour ajouter une icône à un onglet, passe une fonction tabBarIcon dans ses options, avec une bibliothèque d'icônes comme expo-symbols ou @react-native-vector-icons/material-design-icons.
L'imbrication est le point clé : la barre d'onglets est un écran de la pile racine. Quand Lucas touche une fiche dans l'onglet « Mes fiches », le détail se pose sur la pile, par-dessus les onglets, avec un bouton retour. C'est le comportement attendu dans une application mobile : la barre d'onglets disparaît quand on entre dans un détail.
Routes dynamiques et paramètres
L'écran src/app/fiche/[id]/index.tsx lit l'identifiant de la fiche avec le hook useLocalSearchParams :
import { useLocalSearchParams } from 'expo-router';
import { Text } from 'react-native';
export default function DetailFiche() {
const { id } = useLocalSearchParams<{ id: string }>();
return <Text>Fiche n° {id}</Text>;
}
Pour l'adresse /fiche/12, id vaut '12'. Les paramètres d'une adresse sont toujours des chaînes : pour appeler l'API ou comparer avec un nombre, convertis avec Number(id). Et comme n'importe quelle adresse peut être ouverte, y compris par un lien externe (plus bas), un paramètre se valide avant usage : Number('abc') vaut NaN.
Les paramètres de requête marchent aussi : /fiche/12?onglet=frais donne { id: '12', onglet: 'frais' }.
Naviguer : Link et router
Deux façons de changer d'écran. Dans le JSX, le composant Link :
import { Link } from 'expo-router';
<Link href="/profil">Mon profil</Link>
// Avec un paramètre, et un Pressable personnalisé à la place du texte
<Link href={{ pathname: '/fiche/[id]', params: { id: 12 } }} asChild>
<Pressable>{/* … la carte de la fiche … */}</Pressable>
</Link>
asChild transmet la navigation à l'enfant au lieu de créer un texte cliquable. Dans une fonction (après un enregistrement, par exemple), l'objet router :
import { router } from 'expo-router';
router.push({ pathname: '/fiche/[id]/frais', params: { id: 12 } }); // ajoute un écran sur la pile
router.back(); // revient à l'écran précédent
router.replace('/'); // remplace l'écran courant, sans retour possible
router.dismiss(); // ferme la modale
| Méthode | Effet sur la pile | Quand l'utiliser |
|---|---|---|
push | Ajoute un écran | Ouvrir un détail, un formulaire |
back | Retire l'écran courant | Après un enregistrement réussi |
replace | Remplace l'écran courant | Un écran de transition qu'on ne doit pas retrouver avec retour |
dismiss | Ferme la modale (ou des écrans de la pile) | Bouton « Fermer » d'une modale |
dismissTo | Retire les écrans jusqu'à l'adresse donnée | Revenir à la liste depuis un écran empilé trois niveaux plus loin |
Dans le projet créé par create-expo-app, les routes typées sont activées ("typedRoutes": true dans app.json) : Expo génère la liste de tes routes, et l'éditeur signale un href qui ne correspond à aucun fichier. Une faute de frappe dans '/fiches/[id]' est ainsi détectée avant l'exécution.
Modifier l'en-tête depuis l'écran
Le titre déclaré dans le layout est fixe. Pour afficher le mois de la fiche dans l'en-tête, l'écran pose son propre Stack.Screen :
import { Stack } from 'expo-router';
// dans le JSX de DetailFiche, une fois la fiche chargée
<Stack.Screen options={{ title: formatMois(fiche.mois) }} />
Les options définies dans l'écran complètent celles du layout. Le même mécanisme sert à ajouter un bouton dans l'en-tête (headerRight).
Protéger les écrans réservés
Lucas ne doit voir ses fiches qu'une fois connecté. Expo Router le gère avec des routes protégées : Stack.Protected entoure les écrans réservés, et sa prop guard (la garde) dit s'ils sont accessibles.
import { SplashScreen, Stack } from 'expo-router';
import { useEffect } from 'react';
import { SessionProvider, useSession } from '@/context/session';
SplashScreen.preventAutoHideAsync(); // l'écran de démarrage reste affiché pendant le chargement
export default function RacineLayout() {
return (
<SessionProvider>
<Navigation />
</SessionProvider>
);
}
function Navigation() {
const { etat } = useSession();
useEffect(() => {
if (etat !== 'chargement') SplashScreen.hide();
}, [etat]);
if (etat === 'chargement') return null;
const connecte = etat === 'connecte';
return (
<Stack>
<Stack.Protected guard={connecte}>
<Stack.Screen name="(onglets)" options={{ headerShown: false }} />
<Stack.Screen name="fiche/[id]/index" options={{ title: 'Fiche de frais' }} />
<Stack.Screen name="fiche/[id]/frais" options={{ title: 'Nouveau frais', presentation: 'modal' }} />
</Stack.Protected>
<Stack.Protected guard={!connecte}>
<Stack.Screen name="connexion" options={{ headerShown: false }} />
</Stack.Protected>
</Stack>
);
}
Quand connecte passe à true, l'écran de connexion devient inaccessible et l'application affiche le premier écran autorisé, les onglets. Quand Lucas se déconnecte, l'inverse se produit : l'écran affiché devient interdit, et Expo Router redirige vers le premier écran autorisé, la connexion. Toute tentative d'ouvrir une fiche, par le bouton retour ou par un lien, aboutit à la même redirection. Aucune navigation manuelle n'est nécessaire après la connexion ou la déconnexion : il suffit de changer l'état de la session.
Le contexte SessionProvider et le hook useSession viennent d'un fichier src/context/session.tsx, écrit au chapitre 6 quand l'application se connecte à l'API. Pendant le chargement de la session (lecture du jeton, au démarrage), le layout ne rend rien et l'écran de démarrage (splash screen) reste affiché : l'utilisateur ne voit jamais l'écran de connexion clignoter avant ses fiches.
Les liens profonds
Chaque écran a une adresse, et le scheme défini dans app.json permet de l'ouvrir depuis l'extérieur : un lien applifrais://fiche/12 dans un courriel ou une notification ouvre directement le détail de la fiche 12.
{
"expo": {
"scheme": "applifrais"
}
}
Tu peux le tester depuis un terminal pendant le développement avec npx uri-scheme open "applifrais://fiche/12" --android. Pour une application publiée, les App Links (Android) et Universal Links (iOS) font la même chose avec une vraie adresse https://, après une vérification du domaine.
Un lien profond est une entrée externe : n'importe quelle application ou page web peut en déclencher un, avec les paramètres de son choix. Valide les paramètres comme une saisie, et ne déclenche jamais une action (supprimer, valider, payer) directement depuis un lien sans confirmation de l'utilisateur (chapitre 13).
Exercice 1 sur 4 Des fichiers aux adresses Réussi À revoir
Donne l'adresse de chaque fichier, ou « aucune » : src/app/index.tsx ; src/app/(admin)/utilisateurs.tsx ; src/app/visiteur/[id]/fiches.tsx ; src/app/(onglets)/_layout.tsx ; src/app/aide/index.tsx.
Voir la réponseMasquer la réponse de l'exercice 1
| Fichier | Adresse |
|---|---|
src/app/index.tsx | / |
src/app/(admin)/utilisateurs.tsx | /utilisateurs : le groupe n'apparaît pas dans l'adresse |
src/app/visiteur/[id]/fiches.tsx | /visiteur/7/fiches pour le visiteur 7 |
src/app/(onglets)/_layout.tsx | aucune : un layout n'est pas un écran |
src/app/aide/index.tsx | /aide |
Tu avais trouvé ?
Exercice 2 sur 4 push, replace ou back ? Réussi À revoir
Choisis la méthode de router et justifie.
- Sur l'écran « Mes fiches », Lucas touche le bouton « Aide ».
- Le formulaire « Nouveau frais » vient d'être enregistré ; on revient au détail de la fiche.
- Un écran de bienvenue s'affiche une seule fois après l'installation, puis l'application passe aux fiches ; le bouton retour ne doit pas y ramener.
Voir la réponseMasquer la réponse de l'exercice 2
router.push('/aide'): on ajoute l'écran d'aide sur la pile, le bouton retour ramène aux fiches.router.back(): on retire le formulaire, le détail de la fiche réapparaît (et se recharge s'il utiliseuseFocusEffect, chapitre 6).router.replace('/'): l'écran de bienvenue est remplacé, il n'est plus dans la pile.
Tu avais trouvé ?
Exercice 3 sur 4 Le paramètre qui n'est pas un nombre Réussi À revoir
Un développeur écrit dans fiche/[id]/index.tsx : const { id } = useLocalSearchParams<{ id: string }>(); if (id === 12) { … }. La condition n'est jamais vraie. Pourquoi ? Corrige, et ajoute la vérification qui protège contre une adresse comme /fiche/abc.
Voir la réponseMasquer la réponse de l'exercice 3
Les paramètres d'adresse sont des chaînes : id vaut '12', et '12' === 12 est faux (TypeScript le signale d'ailleurs). Correction :
const { id } = useLocalSearchParams<{ id: string }>();
const idFiche = Number(id);
if (!Number.isInteger(idFiche) || idFiche <= 0) {
return <Text>Fiche introuvable.</Text>;
}
Number('abc') vaut NaN, et Number.isInteger(NaN) est faux : l'écran affiche un message au lieu d'appeler l'API avec NaN.
Tu avais trouvé ?
Exercice 4 sur 4 Ajouter un onglet Réussi À revoir
On veut un troisième onglet « Statistiques », entre « Mes fiches » et « Profil », à l'adresse /statistiques. Quels fichiers crées-tu ou modifies-tu ?
Voir la réponseMasquer la réponse de l'exercice 4
- Créer
src/app/(onglets)/statistiques.tsx, un composant exporté par défaut. - Le déclarer dans
src/app/(onglets)/_layout.tsx, entre les deux autres, l'ordre desTabs.Screendonnant l'ordre des onglets :
<Tabs.Screen name="index" options={{ title: 'Mes fiches' }} />
<Tabs.Screen name="statistiques" options={{ title: 'Statistiques' }} />
<Tabs.Screen name="profil" options={{ title: 'Profil' }} />
Le fichier étant dans le groupe (onglets), son adresse est /statistiques, et il est automatiquement protégé par la garde qui entoure (onglets) dans le layout racine.
Tu avais trouvé ?