Développement Mobile

Navigation avec Expo Router : Stack, Tabs et routes dynamiques

13 min de lecture

📍 Article principal du parcours : React Native et Expo : créer une application mobile en 2026
Cette leçon fait partie de la série React Native. Pour la vue d’ensemble, commencez par le guide principal.

Plusieurs écrans, un seul flux

Une application qui se résume à un seul écran est rare. StockPoche doit pouvoir passer de la liste des produits à la fiche détaillée d’un article, puis à un formulaire d’ajout, et offrir des onglets pour basculer entre l’inventaire et les réglages. C’est le rôle de la navigation. Expo Router la rend remarquablement simple : chaque fichier du dossier app/ devient automatiquement une route, exactement comme les pages d’un site web. Pas de configuration manuelle de navigateur, pas de liste de routes à maintenir à la main.

Un point important si vous avez déjà vu d’anciens tutoriels : depuis le SDK 56, Expo Router intègre directement ses propres navigateurs et n’expose plus les paquets @react-navigation/* dans le code de l’application. On importe tout depuis expo-router. Dans cette leçon, vous construisez la navigation complète de StockPoche : des onglets, une pile d’écrans, et une fiche produit ouverte par son identifiant.

🎯 Ce que vous allez apprendre

  • Comprendre le routage par fichiers et le rôle de _layout.tsx.
  • Créer une pile d’écrans avec Stack et personnaliser les en-têtes.
  • Organiser l’app en onglets avec Tabs.
  • Naviguer avec Link et programmatiquement avec useRouter.
  • Créer une route dynamique [id] et lire son paramètre avec useLocalSearchParams.

🛠️ Ce que vous allez construire

La structure de navigation de StockPoche : deux onglets (Inventaire et Réglages), une fiche produit accessible en touchant une carte de la liste, et un bouton retour fonctionnel. À la fin, appuyer sur un produit ouvrira son détail avec le bon identifiant, et le titre de l’écran affichera le nom du produit.

Prérequis

  • Le projet StockPoche avec la liste de produits construite dans Composants et style en React Native.
  • Niveau : intermédiaire. Test express — si vous savez ce qu’est un composant React et passer des props, vous êtes prêt.
  • ⏱️ Temps estimé : ~45 minutes.

Étape 1 — Le routage par fichiers et le layout racine

Avant d’écrire une ligne, comprenez le principe : dans Expo Router, l’arborescence de fichiers est la carte de navigation. Un fichier app/reglages.tsx crée la route /reglages. Les fichiers nommés _layout.tsx sont spéciaux : ils définissent le navigateur qui enveloppe les écrans d’un dossier. Le layout racine app/_layout.tsx est rendu avant tout le reste — c’est là qu’on déclare la pile principale.

// app/_layout.tsx
import { Stack } from 'expo-router';

export default function RootLayout() {
  return (
    <Stack>
      <Stack.Screen name="(tabs)" options={{ headerShown: false }} />
      <Stack.Screen name="produit/[id]" options={{ title: 'Produit' }} />
    </Stack>
  );
}

Ce layout déclare une pile (Stack) contenant deux entrées : le groupe d’onglets (tabs), dont on masque l’en-tête de pile (les onglets ont le leur), et l’écran de détail produit. Le nom produit/[id] annonce une route dynamique que l’on créera à l’étape 5. Les crochets indiquent un segment variable.

Point d’étape — L’app démarre sans erreur. Si vous voyez « No route named (tabs) exists », c’est que le dossier app/(tabs)/ n’existe pas encore : il vient avec le modèle par défaut, sinon créez-le à l’étape suivante.

Étape 2 — Organiser l’app en onglets

Les parenthèses dans (tabs) forment un groupe : un dossier qui organise les fichiers sans ajouter de segment à l’URL. À l’intérieur, un _layout.tsx déclare le navigateur à onglets. Créons deux onglets pour StockPoche : l’inventaire et les réglages.

// app/(tabs)/_layout.tsx
import FontAwesome from '@expo/vector-icons/FontAwesome';
import { Tabs } from 'expo-router';

export default function TabsLayout() {
  return (
    <Tabs screenOptions={{ tabBarActiveTintColor: '#0a7ea4' }}>
      <Tabs.Screen
        name="index"
        options={{
          title: 'Inventaire',
          tabBarIcon: ({ color }) => <FontAwesome name="list" size={22} color={color} />,
        }}
      />
      <Tabs.Screen
        name="reglages"
        options={{
          title: 'Réglages',
          tabBarIcon: ({ color }) => <FontAwesome name="cog" size={22} color={color} />,
        }}
      />
    </Tabs>
  );
}

Chaque Tabs.Screen mappe un fichier (index.tsx, reglages.tsx) à un onglet, avec un titre et une icône. La fonction tabBarIcon reçoit la couleur active/inactive et la propage à l’icône. Le composant Tabs classique rend des onglets en JavaScript, entièrement personnalisables. Si vous voulez la barre d’onglets native de chaque plateforme, Expo Router propose en plus une API dédiée, NativeTabs (depuis expo-router/unstable-native-tabs), avec sa propre structure. Créez un fichier app/(tabs)/reglages.tsx minimal pour que le second onglet ait une cible.

// app/(tabs)/reglages.tsx
import { Text, View } from 'react-native';

export default function Reglages() {
  return (
    <View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
      <Text>Réglages de StockPoche</Text>
    </View>
  );
}

Point d’étape — Deux onglets apparaissent en bas de l’écran. Toucher « Réglages » affiche l’écran correspondant. Si une icône manque, vérifiez que le nom passé à FontAwesome existe dans le jeu d’icônes.

Étape 3 — Naviguer avec Link

La façon la plus déclarative de naviguer est le composant Link : on lui donne une destination via href, et il devient une zone tactile qui ouvre cette route. C’est l’équivalent mobile d’une balise a. Transformons chaque carte de la liste en lien vers la future fiche produit.

// app/(tabs)/index.tsx — dans le renderItem de la FlatList
import { Link } from 'expo-router';

<Link href={`/produit/${item.id}`} asChild>
  <Pressable style={({ pressed }) => ({ opacity: pressed ? 0.6 : 1 })}>
    <CarteProduit nom={item.nom} quantite={item.quantite} prix={item.prix} />
  </Pressable>
</Link>

L’attribut asChild indique à Link de ne pas rendre son propre élément, mais de transmettre le comportement de navigation à son enfant — ici le Pressable qui enveloppe la carte. On garde ainsi le retour visuel au toucher tout en ouvrant la route /produit/3 pour le produit d’identifiant 3. Le gabarit ${item.id} construit l’URL à la volée.

Point d’étape — Toucher une carte tente d’ouvrir /produit/<id>. L’écran sera vide ou affichera une erreur de route tant que le fichier produit/[id].tsx n’existe pas — on le crée à l’étape 5.

Étape 4 — Naviguer programmatiquement avec useRouter

Tous les déplacements ne partent pas d’un lien. Parfois, on veut naviguer après une action — par exemple, rediriger vers la fiche d’un produit qu’on vient d’enregistrer. Le hook useRouter donne accès à un objet router avec des méthodes impératives.

import { useRouter } from 'expo-router';
import { Button, View } from 'react-native';

export default function Reglages() {
  const router = useRouter();
  return (
    <View style={{ flex: 1, padding: 16, gap: 12 }}>
      <Button title="Ajouter un produit" onPress={() => router.push('/produit/nouveau')} />
      <Button title="Retour" onPress={() => router.back()} />
    </View>
  );
}

Les méthodes principales : router.push empile un nouvel écran (le bouton retour ramène en arrière), router.replace remplace l’écran courant sans empiler, router.navigate réutilise un écran existant s’il est déjà dans la pile, et router.back revient en arrière. Pour passer un paramètre proprement, on peut aussi utiliser la forme objet : router.navigate({ pathname: '/produit/[id]', params: { id: '3' } }).

Étape 5 — Créer la route dynamique de la fiche produit

Voici la pièce maîtresse. Pour afficher n’importe quel produit selon son identifiant, on crée un fichier dont le nom contient un segment variable entre crochets : app/produit/[id].tsx. Le routeur capture la partie variable de l’URL et la met à disposition de l’écran via useLocalSearchParams.

// app/produit/[id].tsx
import { Stack, useLocalSearchParams } from 'expo-router';
import { Text, View } from 'react-native';

export default function FicheProduit() {
  const { id } = useLocalSearchParams<{ id: string }>();

  return (
    <View style={{ flex: 1, padding: 16 }}>
      <Stack.Screen options={{ title: `Produit #${id}` }} />
      <Text style={{ fontSize: 20, fontWeight: 'bold' }}>Fiche du produit {id}</Text>
      <Text style={{ marginTop: 8, color: '#666' }}>
        Détails, quantité et actions apparaîtront ici.
      </Text>
    </View>
  );
}

Trois choses se passent. useLocalSearchParams<{ id: string }>() récupère le paramètre id de l’URL, typé en chaîne. Le composant Stack.Screen placé dans le rendu permet de configurer dynamiquement l’écran — ici, on met le numéro du produit dans le titre de la barre. Le reste affiche le détail. Touchez une carte depuis la liste : la fiche s’ouvre avec le bon identifiant, et le bouton retour de la pile vous ramène à l’inventaire.

Point d’étape — Toucher le produit 2 ouvre un écran titré « Produit #2 » affichant « Fiche du produit 2 ». Le bouton retour fonctionne. Si l’id est undefined, vérifiez que le nom de fichier est bien [id].tsx (mêmes crochets, même mot que dans le href).

Étape 6 — Brancher la vraie donnée sur la fiche

Pour rendre la fiche utile, retrouvons le produit correspondant dans nos données. Pour l’instant elles sont en dur ; la leçon suivante les remplacera par une source partagée. Importons le tableau et filtrons par identifiant.

import { PRODUITS } from '@/data/produits';

const { id } = useLocalSearchParams<{ id: string }>();
const produit = PRODUITS.find((p) => p.id === id);

if (!produit) {
  return <Text style={{ padding: 16 }}>Produit introuvable.</Text>;
}

On déplace le tableau PRODUITS dans un fichier partagé data/produits.ts pour que la liste et la fiche lisent la même source. Le find renvoie le produit ou undefined ; on gère ce cas pour éviter un écran cassé si l’utilisateur ouvre un identifiant inexistant. Affichez ensuite produit.nom, produit.quantite et produit.prix dans la fiche.

Point d’étape — La fiche affiche le vrai nom et la vraie quantité du produit touché. Ouvrir un id inexistant (en tapant l’URL à la main) affiche « Produit introuvable » au lieu de planter.

Étape 7 — Ajouter un bouton d’action dans l’en-tête

Une app soignée place ses actions principales là où l’utilisateur les attend. Sur l’écran d’inventaire, le bouton « + » pour ajouter un produit a sa place dans l’en-tête, à droite. Expo Router permet de configurer cet en-tête par écran via les options de l’onglet, en y injectant un composant headerRight.

// app/(tabs)/_layout.tsx — option de l'écran index
import { Pressable } from 'react-native';
import { useRouter } from 'expo-router';

<Tabs.Screen
  name="index"
  options={{
    title: 'Inventaire',
    tabBarIcon: ({ color }) => <FontAwesome name="list" size={22} color={color} />,
    headerRight: () => <BoutonAjout />,
  }}
/>

function BoutonAjout() {
  const router = useRouter();
  return (
    <Pressable onPress={() => router.push('/produit/nouveau')} style={{ paddingHorizontal: 16 }}>
      <FontAwesome name="plus" size={20} color="#0a7ea4" />
    </Pressable>
  );
}

La fonction headerRight retourne le composant à afficher dans le coin droit de l’en-tête. Ici, un bouton « + » qui ouvre la route /produit/nouveau — la même route dynamique, avec l’identifiant spécial nouveau que la fiche peut interpréter comme « mode création ». On réutilise ainsi un seul écran pour créer et consulter, ce qui simplifie le code.

Point d’étape — Un bouton « + » apparaît en haut à droite de l’inventaire et ouvre l’écran de création. Si le bouton ne s’affiche pas, vérifiez que headerShown n’est pas désactivé pour cet écran d’onglet.

🐞 Pièges fréquents

Symptôme / erreur Cause probable Correctif
« No route named X exists » Le fichier de l’écran n’existe pas ou est mal nommé Vérifier le chemin dans app/ et le href
Le paramètre id est undefined Nom de fichier sans crochets, ou clé différente Fichier [id].tsx, lire useLocalSearchParams avec la clé id
Import depuis @react-navigation/native échoue SDK 56 ne l’expose plus dans le code app Importer Stack, Tabs, Link depuis expo-router
Double en-tête (pile + onglets) L’en-tête du groupe d’onglets n’est pas masqué dans la pile options={{ headerShown: false }} sur l’écran (tabs)
Le Link asChild ne réagit pas Plusieurs enfants ou enfant non pressable Un seul enfant tactile sous Link asChild

🌍 Réalités du terrain

Le routage par fichiers a un avantage concret pour les apps livrées à des clients : chaque écran est adressable par une URL, donc les liens profonds fonctionnent gratuitement. Un message envoyé par messagerie peut contenir stockpoche://produit/3 et ouvrir directement la fiche du produit — utile pour partager une référence d’article avec un fournisseur ou un collègue. Le schéma défini dans app.json à la première leçon est ce qui rend ces liens possibles.

Côté ressenti, Expo Router peut aussi rendre la barre d’onglets native de chaque plateforme via son API NativeTabs, pour une navigation conforme aux habitudes des utilisateurs. C’est important pour l’adoption : une app qui respecte les codes du système paraît plus fiable aux yeux des utilisateurs, surtout sur des appareils Android variés où l’incohérence d’interface se remarque vite.

✅ Récapitulatif

StockPoche a maintenant une vraie architecture de navigation. Vous avez posé un layout racine en pile, organisé l’app en onglets, navigué avec Link et useRouter, et créé une route dynamique produit/[id] dont vous lisez le paramètre avec useLocalSearchParams. La fiche affiche le bon produit, le retour fonctionne, et tout repose sur la simple arborescence du dossier app/.

🧾 Aide-mémoire

Élément Rôle
app/_layout.tsx Layout racine (pile principale)
(dossier) Groupe : organise sans ajouter de segment d’URL
[id].tsx Route dynamique avec paramètre
Stack / Stack.Screen Pile d’écrans et configuration d’un écran
Tabs / Tabs.Screen Navigateur à onglets
Link href Navigation déclarative
useRouter() push, replace, back, navigate
useLocalSearchParams() Lire les paramètres de la route

💪 À vous de jouer

Ajoutez un bouton « Retour à l’inventaire » sur la fiche produit qui ramène à la liste, même si l’utilisateur est arrivé par un lien profond (auquel cas il n’y a pas d’écran précédent dans la pile).

Voir une solution

Utilisez router.replace plutôt que router.back : si la fiche a été ouverte par un lien profond, il n’y a rien derrière, et back ne ferait rien.

import { useRouter } from 'expo-router';

const router = useRouter();
<Button title="Retour à l'inventaire" onPress={() => router.replace('/')} />

replace('/') remplace l’écran courant par l’onglet Inventaire, garantissant une destination cohérente quelle que soit la façon dont l’utilisateur est arrivé.

Tutoriels frères

Pour aller plus loin

FAQ

Q : Quelle différence entre useLocalSearchParams et useGlobalSearchParams ?
R : useLocalSearchParams renvoie les paramètres de l’écran courant uniquement et ne se re-déclenche pas quand un autre écran change — c’est le choix par défaut. useGlobalSearchParams observe les paramètres globaux et peut re-rendre plus souvent ; réservez-le aux cas précis qui le justifient.

Q : Dois-je encore installer @react-navigation manuellement ?
R : Non en SDK 56 : Expo Router embarque ses propres navigateurs et vous importez Stack, Tabs et Link depuis expo-router. Les anciens tutoriels qui importent depuis @react-navigation/* dans le code app ne s’appliquent plus.

Q : Comment masquer la barre d’onglets sur certains écrans ?
R : Placez ces écrans hors du groupe (tabs) — par exemple la fiche produit/[id], déclarée dans la pile racine, s’affiche en plein écran avec son propre en-tête, par-dessus les onglets.

Q : Les liens profonds marchent-ils en développement ?
R : Oui. Dans Expo Go, vous pouvez tester un lien exp://…/--/produit/3 ; dans un build avec votre schéma, ce sera stockpoche://produit/3. Le routage par fichiers rend chaque écran adressable sans configuration supplémentaire.

Partager