Chapitre 5.3 — Routes dynamiques & paramètres
Où on en est : la charpente tabs est posée. L’onglet Thèmes liste les thèmes — il faut maintenant l’écran de détail : UN fichier pour TOUS les thèmes.
⏱️ TL;DR —
app/theme/[slug].tsxmatche/theme/symfony,/theme/php… Le paramètre se lit avecuseLocalSearchParams. Les typed routes vérifient vos href à la compilation. On ajoute une pile locale à l’onglet Thèmes (liste → détail SOUS la barre d’onglets), et on passe les paramètres de session au quiz (/quiz?theme=symfony&count=10).
🎯 Objectifs
- Créer la route dynamique du détail d’un thème et lire ses params.
- Donner à l’onglet Thèmes sa propre pile (liste → détail, barre visible).
- Typer et valider les paramètres (les params sont des strings !).
- Transmettre la configuration d’une session au quiz.
La route dynamique
app/(tabs)/themes/
├── _layout.tsx → Stack local à l'onglet
├── index.tsx → liste des thèmes "/themes"
└── [slug].tsx → détail d'un thème "/themes/symfony", "/themes/php"…L’onglet Thèmes devient un dossier avec sa pile : naviguer vers un détail garde la barre d’onglets visible (on reste « chez » Thèmes) — contrairement au quiz plein écran.
// app/(tabs)/themes/_layout.tsx
import { Stack } from 'expo-router'
export default function ThemesLayout() {
return (
<Stack
screenOptions={{
headerStyle: { backgroundColor: '#0b1216' },
headerTintColor: '#e2e8f0',
headerShadowVisible: false,
contentStyle: { backgroundColor: '#0b1216' },
}}
/>
)
}// app/(tabs)/themes/index.tsx — la liste (FlatList du chapitre 3.4 + ThemeCard)
<FlatList
data={themes}
keyExtractor={t => t.slug}
renderItem={({ item }) => (
<Link href={`/themes/${item.slug}`} asChild>
<Pressable className="active:opacity-85">
<ThemeCard title={item.title} questionCount={item.count} emoji={item.emoji} />
</Pressable>
</Link>
)}
contentContainerClassName="gap-3 p-4"
/>Lire les paramètres
// app/(tabs)/themes/[slug].tsx
import { Stack, useLocalSearchParams } from 'expo-router'
export default function ThemeDetailScreen() {
const { slug } = useLocalSearchParams<{ slug: string }>()
const theme = getThemeBySlug(slug) // lib/ — données en dur pour l'instant
if (!theme) return <NotFoundTheme />
return (
<>
<Stack.Screen options={{ title: theme.title }} />
<Screen scroll>
<AppText variant="title">{theme.emoji} {theme.title}</AppText>
<AppText variant="caption">{theme.count} questions · record : 870 pts</AppText>
{/* choix du nombre de questions, difficulté… puis : */}
<Link href={{ pathname: '/quiz', params: { theme: theme.slug, count: 10 } }} asChild>
<Button label="Lancer une session" />
</Link>
</Screen>
</>
)
}Trois choses à retenir :
useLocalSearchParamslit les params de CET écran (son cousinuseGlobalSearchParamsréagit aussi aux changements pendant que l’écran est sous la pile — rare).- Le
Stack.Screen optionsdynamique : le titre du header vient des données. - Le
hrefobjet (pathname+params) : la manière propre de passer des query params — ici la config de session vers le quiz (/quiz?theme=symfony&count=10).
⚠️ Les params sont des STRINGS
Comme les query params du web : count: 10 ressort "10". Et useLocalSearchParams type par
défaut en string | string[]. La règle du cours : valider aux frontières —
// app/quiz.tsx — lecture défensive des params de session
const params = useLocalSearchParams<{ theme?: string; count?: string }>()
const theme = typeof params.theme === 'string' ? params.theme : 'melange'
const count = Math.min(50, Math.max(5, Number(params.count) || 10))Un deep link (chapitre 5.5) pourra ouvrir /quiz avec des params arbitraires (malformés,
absents) : l’écran doit avoir des défauts sûrs. C’est le même réflexe que valider
searchParams dans une page Next.js — en plus important, car pas de serveur pour assainir
avant vous.
Typed routes — le filet
Avec experiments.typedRoutes (activé chapitre 2.4), Expo Router génère les types de toutes
vos routes :
<Link href="/themes/symfony" /> // ✅
<Link href="/theme/symfony" /> // ❌ erreur TS : route inexistante (singulier !)
<Link href={{ pathname: '/quiz', params: { theme: 's' } }} /> // ✅ params typés aussiLes fautes de frappe de navigation meurent à la compilation. Les segments dynamiques acceptent
n’importe quelle valeur du bon shape (/themes/nimportequoi compile — d’où la validation
runtime ci-dessus : les types vérifient la forme, pas l’existence).
💡 Pour un dev Next.js —
[slug].tsx=[slug]/page.tsx,useLocalSearchParams=useParams()+useSearchParams()réunis. Pas d’équivalent degenerateStaticParams(rien n’est généré : tout est résolu à l’exécution), et pas de data fetching serveur dans la route — les données viennent des hooks (TanStack Query en Partie 7).
✏️ Exercices
1. Implémentez lib/themes.ts : le type Theme (slug, title, emoji, count), le tableau en
dur (Bootstrap, Next.js, PHP, Symfony), getThemeBySlug. Puis branchez liste + détail.
✅ Solution
// lib/themes.ts
export type Theme = { slug: string; title: string; emoji: string; count: number }
export const themes: Theme[] = [
{ slug: 'bootstrap', title: 'Bootstrap', emoji: '🅱️', count: 80 },
{ slug: 'nextjs', title: 'Next.js', emoji: '▲', count: 120 },
{ slug: 'php', title: 'PHP 8.5', emoji: '🐘', count: 140 },
{ slug: 'symfony', title: 'Symfony 7', emoji: '🎼', count: 130 },
]
export const getThemeBySlug = (slug: string) => themes.find(t => t.slug === slug)Pure TS dans lib/ (notre règle) — remplacé en P7 par le fetch du manifest GitHub, sans changer les écrans.
2. Gérez le cas /themes/inexistant : un écran « thème introuvable » propre avec retour à
la liste. Bonus : le fichier de convention +not-found.tsx.
✅ Solution
Dans [slug].tsx, le if (!theme) rend un composant avec message + <Link href="/themes">.
Bonus : app/+not-found.tsx attrape TOUTES les routes inconnues de l’app (deep link mort,
etc.) — un écran 404 global avec bouton retour accueil. Les deux niveaux se complètent :
+not-found pour les routes sans fichier, la garde pour les slugs sans données.
3. Le détail d’un thème doit proposer 5/10/20 questions. Implémentez le sélecteur et le
passage au quiz — puis vérifiez dans le quiz que count=abc (URL forgée) retombe sur 10.
✅ Solution
Trois Badge/boutons mettant count dans un useState local, puis
href={{ pathname: '/quiz', params: { theme: slug, count } }}. Côté quiz, la lecture
défensive (Number(params.count) || 10 borné 5-50) transforme "abc" en 10. Test : naviguer
manuellement (barre Metro npx uri-scheme au ch. 5.5, ou modifier le Link) et observer.
🧠 Quiz
1. Quel fichier matche /themes/php, et comment lit-il « php » ?
Réponse
app/(tabs)/themes/[slug].tsx, via const { slug } = useLocalSearchParams<{ slug: string }>().
2. Pourquoi donner un Stack local à l’onglet Thèmes ?
Réponse
Pour que liste → détail s’empile dans l’onglet (barre visible, back local à la pile de l’onglet), au lieu de recouvrir toute l’app comme le quiz.
3. Type réel de params.count après /quiz?count=10 ?
Réponse
"10" (string) — tous les params arrivent en string (ou string[]). Convertir ET valider
avec défauts sûrs (Number(x) || 10, bornes).
4. Que garantissent les typed routes — et que ne garantissent-elles pas ?
Réponse
Elles garantissent que le chemin a la forme d’une route existante (faute de frappe = erreur
TS). Elles ne garantissent PAS que la donnée existe (/themes/xyz compile) → garde runtime.
5. Comment passer proprement plusieurs query params à un Link ?
Réponse
La forme objet : href={{ pathname: '/quiz', params: { theme: 'php', count: 10 } }} —
échappement et types gérés.
👉 Chapitre suivant : 5.4 — Modals & navigation programmatique — le flux complet d’une partie : lancer, abandonner, terminer, rejouer.