Skip to Content

Chapitre 5.1 — Expo Router : fondations

Où on en est : Drill a un design system mais un seul écran. On installe le vocabulaire de navigation : fichiers-routes, stacks, layouts, Link.

⏱️ TL;DR — Un fichier dans app/ = un écran ; _layout.tsx = le conteneur de navigation (le Stack) ; <Link href="/history"> = la navigation déclarative. La différence fondamentale avec le web : naviguer empile des écrans (avec animation native et geste retour), ça ne remplace pas la page. L’état des écrans sous la pile survit.

🎯 Objectifs

  • Relier fichiers, routes et écrans (et repérer les différences avec Next.js).
  • Comprendre la pile (push/pop) et ses conséquences sur l’état et le cycle de vie.
  • Configurer le Stack et ses headers dans _layout.tsx.
  • Naviguer avec Link (et préparer router.push pour le chapitre 5.4).

Fichiers = routes, comme à la maison

app/ ├── _layout.tsx → layout racine : déclare LE navigateur (Stack) ├── index.tsx → route "/" (accueil) ├── history.tsx → route "/history" (historique) └── settings.tsx → route "/settings" (réglages)

Créer app/history.tsx crée l’écran /history — zéro enregistrement manuel (les vieux tutos React Navigation câblaient chaque écran à la main ; Expo Router, construit dessus, fait ça pour vous).

// app/_layout.tsx import { Stack } from 'expo-router' import { StatusBar } from 'expo-status-bar' import '../global.css' export default function RootLayout() { return ( <> <Stack screenOptions={{ headerStyle: { backgroundColor: '#0b1216' }, headerTintColor: '#e2e8f0', headerShadowVisible: false, contentStyle: { backgroundColor: '#0b1216' }, }} /> <StatusBar style="light" /> </> ) }

Le <Stack> rend l’écran actif et gère la pile : animation de transition native, bouton retour dans le header, geste retour Android. screenOptions style tous les écrans ; chaque écran peut surcharger via <Stack.Screen options={…}> dans son propre fichier :

// app/history.tsx import { Stack } from 'expo-router' import { Screen } from '../components/ui/Screen' export default function HistoryScreen() { return ( <> <Stack.Screen options={{ title: 'Historique' }} /> <Screen>{/* la FlatList du chapitre 3.4 */}</Screen> </> ) }

La pile — LA différence avec le web

Sur le web, naviguer remplace le document. En mobile, naviguer empile :

Trois conséquences très concrètes :

  1. L’état survit : l’accueil sous la pile garde son scroll, ses useState — au retour, tout est là. (Sur le web, il faudrait le restaurer.)
  2. Les écrans empilés restent montés : leurs useEffect ne se « démontent » pas au push. Pour réagir à la visibilité, le hook dédié : useFocusEffect (on s’en servira pour rafraîchir l’historique en y revenant).
  3. Le retour est un geste système : bouton/geste back Android dépile automatiquement. Vous ne gérez JAMAIS le retour à la main — vous structurez la pile pour qu’il soit juste.
import { Link } from 'expo-router' <Link href="/history" asChild> <Pressable className="active:opacity-80"> <AppText variant="body">Voir l'historique →</AppText> </Pressable> </Link>
  • href typé (typed routes : une route inexistante = erreur TS — le filet de sécurité qu’on active dès maintenant).
  • asChild : Link délègue le rendu à son enfant Pressable — indispensable pour nos composants du design system (sinon Link rend son propre Text).
  • <Link replace …> remplace au lieu d’empiler ; push force un nouvel empilement même si la route est identique.

💡 Pour un dev Next.js — Correspondances directes : app/ = app/, _layout.tsx = layout.tsx, index.tsx = page.tsx, <Link href> = <Link href>. Différences à encaisser : tout est client (pas de Server Components — le « serveur » n’existe pas dans une app embarquée) ; le layout déclare un navigateur (Stack/Tabs), pas juste du JSX autour ; et la navigation a une profondeur physique (la pile) avec animation et geste système.

⚠️ Piège — Chaque écran doit être l’export default de son fichier. Un export nommé → route vide et warning discret. Et les fichiers non-écrans (composants locaux, helpers) n’ont RIEN à faire dans app/ : Metro en ferait des routes. Composants → components/, logique → lib/.

✏️ Exercices

1. Créez les trois écrans (index, history, settings) avec le kit UI, reliés par des Link depuis l’accueil. Vérifiez : geste retour Android, conservation du scroll de l’accueil.

✅ Solution

Trois fichiers export default avec <Screen> + <Stack.Screen options={{ title }}>, accueil avec deux <Link asChild>. Test : scroller l’accueil, pousser History, revenir par le geste → le scroll est intact (l’écran n’a jamais été démonté). C’est la pile en action.

2. Ajoutez un console.log('mount') / cleanup log('unmount') dans un useEffect de l’accueil, naviguez vers History puis revenez. Que loggue la console, et pourquoi ?

✅ Solution

Un seul mount au lancement — aucun unmount/remount pendant l’aller-retour : l’accueil reste monté sous la pile. Pour réagir au focus (retour visible), useFocusEffect est le bon outil, pas useEffect.

3. Que se passe-t-il si vous placez components/QuizHeader.tsx… dans app/components/ ? Constatez, puis corrigez.

✅ Solution

app/components/quiz-header devient une route navigable (visible dans le typage des href et atteignable) — pollution du routeur. Correction : sortir de app/ (→ components/). Seule exception tolérée : les fichiers préfixés de conventions du routeur (_layout, +not-found).

🧠 Quiz

1. Que déclare le _layout.tsx racine, et que rend-il ?

Réponse

Le navigateur racine (ici un Stack) qui rend l’écran actif, gère la pile, les transitions et le header — plus les éléments globaux (StatusBar, import du CSS NativeWind).

2. Trois différences entre naviguer sur le web et pousser un écran mobile ?

Réponse

Le push empile sans démonter (état conservé), la transition est animée nativement, et le retour est un geste système qui dépile — pas un lien à gérer.

Réponse

À déléguer le rendu au composant enfant (notre Pressable/Button du kit) : Link fournit le comportement de navigation, l’enfant fournit l’apparence.

4. useEffect suffit-il pour « à chaque fois que l’écran redevient visible » ?

Réponse

Non — l’écran reste monté sous la pile, l’effet ne rejoue pas. C’est le rôle de useFocusEffect (déclenché à chaque focus de l’écran).

5. Pourquoi ne met-on pas de composants dans app/ ?

Réponse

Tout fichier de app/ devient une route. Les composants vivent dans components/, la logique dans lib/ — app/ ne contient que des écrans et les fichiers de convention du routeur.


👉 Chapitre suivant : 5.2 — Tabs — les 4 onglets de Drill.