Files
_Assistant_Lead_Tech/knowledge/frontend/patterns/navigation.md
T
MaksTinyWorkshop 5f5c87296e docs(knowledge): capitalisation frontend — intégration du triage local (mai-juin 2026)
Triage et intégration des propositions frontend du buffer 95_a_capitaliser.md
(lot local RL799_V2/Vue3 + app-alexandrie/RN-Expo, mai-juin 2026).

~73 entrées intégrées sur knowledge/frontend/ + 1 nouveau fichier, dont :
- patterns/state.md : race-token partagé latest-wins (fusion 3 props), capture sync anti-race,
  event bus timestamp, clé cache composite, état dérivé = computed
- risques/state.md : 9 risques Zustand/store (fetchId reset, useRef remount, re-fetch infini
  sur [], flag optimiste écrasé, cache détail/liste stale, latch sans reset, :key index)
- patterns/navigation.md : Expo Router (tab bar, useFocusEffect, Href typé, routing pur fusionné)
- patterns/general.md : helpers temps purs, composants génériques + skeleton, fail-fast, touch target
- risques/general.md : 24 risques (sweep statique, filtre client liste paginée, hooks avant return,
  a11y VoiceOver/disabled, redirection allowlist, RangeError toISOString, section i18n...)
- design-tokens (cluster theming light/dark MD3), tests, performance, react-native, nextjs
- NOUVEAU risques/responsive.md (gating par capacité d'input + checklist régressions mobile)
- READMEs patterns/risques mis à jour

Doublons inter-fichiers évités (vérifié : aucune ancre dupliquée introduite).
Rejets (doublons 91/9/87), reciblages workflow (156/257) et bloc 32 (CLAUDE projet) non intégrés ici.
Source 95_a_capitaliser.md non purgée (purge en fin de capitalisation complète).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 15:31:53 +02:00

19 KiB


title: Frontend — Patterns : Navigation domain: frontend bucket: patterns tags: [navigation, react, expo-router, useeffect, async] applies_to: [analysis, implementation, review] severity: medium validated_on: 2026-03-21 source_projects: [app-template-resto, app-alexandrie]

Frontend — Patterns : Navigation

Extrait de la base de connaissance Lead_tech. Voir knowledge/frontend/patterns/README.md pour l'index complet.


Pattern : Navigation réactive post-action async (React / Expo Router)

Synthèse

  • Objectif : déclencher la navigation après une action asynchrone (login, register, submit) de façon idiomatique et sans bypasser la réactivité React.
  • Contexte : SPA ou app mobile React avec state management (Zustand, Redux, Context) et router déclaratif (React Router, Expo Router, Next.js App Router).
  • Quand l'utiliser : dès qu'une navigation dépend du résultat d'une action async.
  • Quand l'éviter : navigations synchrones sans état async impliqué.

Analyse

  • Avantages :
    • Respecte le cycle de vie React (pas de lecture de state hors cycle)
    • Re-render automatique si l'état change entre-temps
    • Testable : on peut assert sur l'état, pas sur des effets de bord
  • Limites / vigilance :
    • Ne pas oublier les dépendances du useEffect (ESLint react-hooks/exhaustive-deps)
    • Gérer le cas "composant démonté" si la navigation peut être annulée

Validation

  • Validé le : 07-03-2026
  • Contexte technique : React 18+ / Zustand / Expo Router — pattern applicable sur React Router, Next.js App Router

Implémentation (exemple minimal)

// ❌ Anti-pattern : lecture de state hors cycle React
const handleSubmit = async () => {
  await login(email, password);
  const { accessToken } = useAuthStore.getState(); // bypasse la réactivité
  if (accessToken) router.replace('/(tabs)');
};

// ✅ Pattern correct : useEffect réactif sur le state
const { accessToken, isLoading, error } = useAuthStore();

useEffect(() => {
  if (accessToken && !isLoading && !error) {
    router.replace('/(tabs)');
  }
}, [accessToken, isLoading, error]);

const handleSubmit = async () => {
  await login(email, password);
  // la navigation se déclenche via useEffect quand le store se met à jour
};

Pour les callbacks OAuth (ref nécessaire)

// Quand un callback externe déclenche la navigation
const pendingOAuth = useRef(false);

useEffect(() => {
  if (pendingOAuth.current && accessToken) {
    pendingOAuth.current = false;
    router.replace('/(tabs)');
  }
}, [accessToken]);

const handleOAuth = async () => {
  pendingOAuth.current = true;
  await exchangeWithIdp(token);
};

Checklist

  • Aucun store.getState() utilisé pour lire l'état post-action dans un handler
  • useEffect avec dépendances explicites (state pertinent + isLoading + error)
  • Cas d'erreur géré (ne pas naviguer si error est défini)
  • useRef si le trigger vient d'un callback externe (OAuth, deep link)
  • Convention documentée dans la story foundations / project-context avant les premiers écrans

Synthèse

  • Objectif : éviter les parcours concurrents et centraliser les garde-fous UX quand une fonctionnalité publique dépend d'un service tiers externe.
  • Contexte : site ou webapp avec CTA publics menant vers un tiers de réservation, paiement, prise de rendez-vous ou formulaire externe.
  • Quand l'utiliser : dès qu'une fonctionnalité externe dispose d'une page locale dédiée côté produit (/reservation, /booking, etc.).
  • Quand l'éviter : si le produit assume volontairement une sortie directe unique vers le tiers, sans page locale intermédiaire ni besoin de contextualisation.

Analyse

  • Avantages :
    • UX plus cohérente entre home, navigation et pages dédiées
    • garde-fous, wording et fallbacks centralisés au même endroit
    • facilite l'évolution future vers embed, click-to-load ou variantes de parcours
  • Limites / vigilance :
    • ajoute une étape intermédiaire si la page locale n'apporte aucune valeur
    • demande de maintenir l'alignement entre les CTA internes et le parcours canonique

Validation

  • Validé le : 19-03-2026
  • Contexte technique : site web public / intégration tierce en mode lien externe

Implémentation (exemple minimal)

- faire pointer les CTA internes (home, nav, landing) vers une page locale dédiée
- faire de cette page locale le point canonique vers le service tiers externe
- centraliser sur cette page le contexte utile, les garde-fous et les fallbacks
- éviter les sorties directes concurrentes vers le tiers depuis plusieurs endroits du site

Checklist

  • Un parcours canonique unique est défini
  • Les CTA internes convergent vers la page locale dédiée
  • Les garde-fous et fallbacks sont centralisés
  • Les sorties directes concurrentes vers le tiers sont évitées ou justifiées

Pattern : Click-to-load strict pour les embeds tiers (iframe/widget)

Synthèse

  • Objectif : ne charger aucun service tiers sans action explicite de l'utilisateur (performance + consentement implicite).
  • Contexte : site/webapp avec modules de réservation, map, chat ou tout embed iframe à la demande.
  • Quand l'utiliser : dès qu'un embed tiers est chargé à la demande (pas au premier rendu).
  • Quand l'éviter : si l'embed est central à la page et doit être visible immédiatement.

Analyse

  • Avantages :
    • LCP non pollué par des tiers (performance-first)
    • Aucun tiers ne reçoit de données utilisateur sans action volontaire (consentement implicite)
    • Fallback toujours disponible en cas d'erreur iframe
  • Limites / vigilance :
    • Le fallback (lien externe + tel:) doit être actionnable même si l'embed échoue

Validation

  • Validé le : 21-03-2026
  • Contexte technique : React / Next.js — app-template-resto

Implémentation

const [loaded, setLoaded] = useState(false);
const [errored, setErrored] = useState(false);

if (errored) return <a href={url}>Ouvrir {label}</a>;

return (
  <>
    {!loaded && <button onClick={() => setLoaded(true)}>Charger {label}</button>}
    {loaded && <iframe src={url} onError={() => setErrored(true)} />}
  </>
);

Pattern : Overlay/drawer accessible avec focus trap

Synthèse

Teleport + backdrop + Escape + scroll-lock n'est pas suffisant : le focus trap est obligatoire.

Analyse

Sans focus trap, le clavier peut sortir de la sheet/panel et casser l'ordre de navigation.

Validation

  • Validé le : 09-04-2026
  • Contexte technique : Vue 3 / accessibilité overlays — RL799_V2
  • Applicable à tout drawer/sheet/modal custom en SPA (desktop et mobile clavier).

Implémentation

  • Capturer le focus à l'ouverture (premier élément interactif).
  • Boucler Tab/Shift+Tab dans le conteneur.
  • Restaurer le focus au trigger à la fermeture.

Pattern : Factorisation page mode dynamique via route.meta.mode typé

Synthèse

  • Objectif : factoriser un composant Vue qui partage 95 % de sa logique entre plusieurs routes ne différant que par le wording, sans recourir à route.name (fragile) ou query string (manipulable).
  • Contexte : projet Vue avec deux routes (ex : /invitation + /reset-password) qui partagent la mécanique technique stricte (validation token + saisie mot de passe + consume) et ne diffèrent que par le wording.
  • Quand l'utiliser : factorisation justifiée par un partage > 90 % de logique entre routes.
  • Quand l'éviter : routes qui diffèrent fonctionnellement au-delà du wording — préférer deux composants distincts.

Analyse

  • Avantages :
    • explicite, déclaratif, type-checkable
    • augmenter RouteMeta dans vue-router pour qu'un mode manquant produise une erreur build
  • Limites / vigilance :
    • wording centralisé en un seul computed — pas de v-if éparpillés dans le template
    • endpoint dynamique en un seul if/else dans le handler

Validation

  • Validé le : 28-04-2026
  • Contexte technique : Vue 3 / vue-router — RL799_V2

Implémentation

// router/index.ts
{
  path: '/invitation',
  name: 'invitation',
  component: ResetPasswordPage,
  meta: { requiresGuest: true, mode: 'invitation' as const },
},
{
  path: '/reset-password',
  name: 'reset-password',
  component: ResetPasswordPage,
  meta: { requiresGuest: true, mode: 'reset' as const },
},
// ResetPasswordPage.vue
const pageMode = computed<PageMode>(() => {
  const meta = route.meta as { mode?: PageMode } | undefined;
  return meta?.mode === 'invitation' ? 'invitation' : 'reset';
});

const wording = computed(() => {
  if (pageMode.value === 'invitation') {
    return { eyebrow: 'Bienvenue', title: 'Définissez votre mot de passe', /* … */ };
  }
  return { eyebrow: 'Réinitialisation', title: '...', /* … */ };
});

const handleSubmit = async () => {
  if (pageMode.value === 'invitation') {
    await consumeInvitationToken(token.value, newPassword.value);
  } else {
    await consumeResetToken(token.value, newPassword.value);
  }
  // Post-consume uniforme : redirige /login pour les deux modes
};

Règles d'or

  • meta.mode typé en literal union ('invitation' | 'reset')
  • A11y obligatoire : autofocus sur le champ password au mount post-validation, <label for> associés, <AppMessage variant="error"> avec role="alert", <AppMessage variant="success"> avec aria-live="polite"
  • Test obligatoire : monter la page avec chaque meta.mode et vérifier que le wording correspond

Anti-patterns

  • Détecter le mode via route.name (fragile, couplage implicite, signal d'intention faible)
  • Détecter via query string (pollue URL, manipulable)

Pattern : Tab bar native cachée → ne garder que le wiring routing

Synthèse

  • Objectif : quand la tab bar native d'Expo Router est désactivée (tabBarStyle.display: 'none') au profit d'une nav persistante custom, ne garder dans (tabs)/_layout.tsx que le minimum vital de déclaration de routes.
  • Contexte : Expo Router avec <PersistentTabBar /> custom remplaçant la tab bar native.
  • Quand l'utiliser : dès que le visuel des tabs vit dans un composant custom et non dans <Tabs>.
  • Quand l'éviter : tab bar native conservée (le label/icônes restent utiles).

Analyse

  • Avantages :
    • les <Tabs.Screen> ne servent plus qu'à déclarer les routes physiques du dossier (tabs)/ (sinon 404 sur certaines plateformes)
    • suppression du dead-code visuel (label, iconActive, tabBarBadge…) et de ses dépendances inutiles (icons, stores de badges)
    • l'intention "wiring routing pur, pas du visuel" devient explicite (TAB_ROUTE_NAMES)
  • Limites / vigilance :
    • un TAB_CONFIG riche "au cas où on réactiverait la tab bar native" masque l'intention et crée des incohérences silencieuses (ordre du config ≠ ordre visuel réel)
    • règle complémentaire : tout hook/effet de boot alimentant un état consommé par la nav persistante (ex. refreshUnreadCount pour la cloche TopBar) appartient au _layout.tsx racine, pas au layout (tabs)/ — sinon il ne s'exécute que sur les routes (tabs)/* et le badge ne s'hydrate pas ailleurs

Validation

  • Validé le : 27-05-2026
  • Contexte technique : React Native / Expo Router — app-alexandrie (IA-v2.1)

Implémentation

// Les <Tabs.Screen> déclarent uniquement les routes physiques.
// La nav visuelle vit dans <PersistentTabBar />.
const TAB_ROUTE_NAMES = ['explore', 'community', 'library', 'profile',
  'notifications', 'achievements', 'settings'] as const;

return (
  <Tabs screenOptions={{ headerShown: false, tabBarStyle: { display: 'none' } }}>
    {TAB_ROUTE_NAMES.map((name) => <Tabs.Screen key={name} name={name} />)}
  </Tabs>
);

Pattern : État UI éphémère lié au focus écran via useFocusEffect (Expo Router)

Synthèse

  • Objectif : nettoyer un état UI éphémère qu'un écran pose dans un composant global persistant (TopBar, BottomBar, FAB contextuel) au blur, pas au démontage.
  • Contexte : Expo Router, où un écran enfant n'est pas toujours démonté à la navigation arrière (gardé en arrière-plan, surtout en web).
  • Quand l'utiliser : dès qu'un écran pose un état consommé par un composant global persistant.
  • Quand l'éviter : état strictement local à l'écran, sans surface globale.

Analyse

  • Avantages : le cleanup se déclenche à la perte de focus, garanti même si l'écran reste monté.
  • Limites / vigilance :
    • useEffect + cleanup au démontage laisse un titre/état fantôme sur le parent (ex. titre "Bob" qui reste après retour sur la liste Messages)
    • bug souvent invisible sur natif (démontage plus systématique), visible en web → smoke multi-plateforme indispensable

Validation

  • Validé le : 27-05-2026
  • Contexte technique : React Native / Expo Router — app-alexandrie (IA-v2.2, useTopBarStore)

Implémentation

// ❌ titre fantôme : cleanup au unmount, pas garanti au blur
useEffect(() => {
  if (!peerHandle) return;
  useTopBarStore.getState().setTitle(peerHandle);
  return () => useTopBarStore.getState().setTitle(null);
}, [peerHandle]);

// ✅ cleanup au blur
import { useFocusEffect } from 'expo-router';
useFocusEffect(useCallback(() => {
  useTopBarStore.getState().setTitle(peerHandle);
  return () => useTopBarStore.getState().setTitle(null);
}, [peerHandle]));

Pattern : Routes Expo Router typées en Href au lieu de as never

Synthèse

  • Objectif : supprimer le cast router.push('/route' as never) répété à chaque callsite au profit de constantes Href centralisées.
  • Contexte : Expo Router sans typed routes générés, qui pousse à caster en never.
  • Quand l'utiliser : dès qu'une route est poussée depuis plusieurs callsites.
  • Quand l'éviter : typed routes activés (le type généré suffit).

Analyse

  • Avantages :
    • 1 seul cast par route au lieu d'un par callsite
    • migration future vers typedRoutes triviale (remplacer as Href par le type généré sans toucher aux callsites)
    • auto-complétion sur les constantes plutôt que sur des string literals
  • Limites / vigilance : ne pas confondre avec la navigation réactive — les routes restent poussées dans des handlers, pas dans un useEffect (voir risques/navigation.md).

Validation

  • Validé le : 27-05-2026
  • Contexte technique : React Native / Expo Router — app-alexandrie (IA-v2.5 L4)

Implémentation

import { useRouter, type Href } from 'expo-router';
const CHECKOUT_HREF = '/subscription/checkout' as Href;
const PACKS_HREF = '/packs' as Href;
// callsites :
onPress={() => router.push(CHECKOUT_HREF)}
onPress={() => router.push(PACKS_HREF)}

Pattern : Stack indépendant par tab via mémorisation de la dernière route

Synthèse

  • Objectif : qu'un tap sur un onglet re-atterrisse sur sa dernière route visitée (pas sur la racine) quand la Tabs native est désactivée + BottomBar custom.
  • Contexte : Expo Router, Tabs native off, <PersistentTabBar /> custom.
  • Quand l'utiliser : quick win avant un refactor archi (app)/ complet (option a).
  • Quand l'éviter : besoin d'un back-stack complet par tab (préférer alors un Stack natif par tab).

Analyse

  • Avantages : low-risk, sans refactor archi ; mémorise la dernière route par tab dans un store.
  • Limites / vigilance :
    • garde-fou clé : exclure les routes auth/onboarding du tracking, sinon le tap sur un onglet ramène vers un onboarding terminé → écran vide/crash
    • limites assumées en v2 : seulement la dernière route (pas de back-stack), pas de persistance disque (perdu au cold start)

Validation

  • Validé le : 27-05-2026
  • Contexte technique : React Native / Expo Router — app-alexandrie (IA-v2.8 AC5)

Implémentation

type RememberableTab = 'explore' | 'community' | 'messages' | 'library';
const EXCLUDED = ['/(auth)', '/login', '/onboarding'];
export const useTabHistoryStore = create<{
  lastRoutes: Partial<Record<RememberableTab, string>>;
  setLastRoute: (tab: RememberableTab, route: string) => void;
}>((set) => ({
  lastRoutes: {},
  setLastRoute: (tab, route) => {
    if (EXCLUDED.some((p) => route.startsWith(p))) return; // garde-fou
    set((s) => ({ lastRoutes: { ...s.lastRoutes, [tab]: route } }));
  },
}));

// _layout.tsx racine : observe pathname → pose la dernière route par tab
// BottomBar : au tap d'un tab non-actif, router.push(lastRoutes[tab] ?? tab.href)

Pattern : Routing décorrélé du rendu (builder pur testable)

Synthèse

  • Objectif : extraire le calcul de la route cible ((payload) => Href | null) du composant qui navigue, pour le rendre pur et testable en env node.
  • Contexte : navigation conditionnelle (route dépendant du payload, du type d'événement, de l'état utilisateur) ; Expo Router et autres routers.
  • Quand l'utiliser : dès qu'une route est templatée inline ou qu'un switch de routing vit dans un composant.
  • Quand l'éviter : navigation triviale vers une route fixe.

Analyse

  • Avantages :
    • testable env node : 0 mock RN, 0 NavigationContainer, juste expect(fn(...)).toBe('/path/x')
    • cas edge centralisés : un seul switch, un seul fallback null, un seul format d'URL
    • type-safe si typed routes activés (le pathname est vérifié contre l'arbre réel)
    • pas de slug hardcodé : si la source ne connaît pas le slug, on omet le param plutôt que de mentir — l'écran cible retombe sur sa résolution serveur
  • Limites / vigilance :
    • anti-pattern : router.push(\/community/thread/${id}?forumSlug=general`)quand'general'` n'a aucune chance d'être le bon slug → fonctionne par accident (fallback serveur), illisible
    • quand le caller a l'objet complet, exposer une 2ᵉ fonction qui prend l'objet et délègue à la primitive (évite de dupliquer le switch)

Validation

  • Validé le : 29-05-2026
  • Contexte technique : React Native / Expo Router — app-alexandrie (ux-cleanup-4 / ux-cleanup-9 / ux-cleanup-10 ; feed-navigation.ts, notifications-navigation.ts)

Implémentation

// builder pur — forme objet { pathname, params }
export function buildThreadRoute(item: { id: string; forum?: { slug: string } | null }) {
  const slug = item.forum?.slug;
  return {
    pathname: '/community/thread/[threadId]',
    params: slug ? { threadId: item.id, forumSlug: slug } : { threadId: item.id },
  } as const;
}

// primitive (bouts un par un) + dérivée (objet complet) coexistent
export function resolveNotificationRoute(type, targetId, commentId): string | null { /* switch */ }
export function routeForNotification(n: NotificationItem): string | null {
  return resolveNotificationRoute(n.targetType, n.targetId, n.commentId);
}

// composant : délègue, ne décide pas
const route = resolveNotificationRoute(notif.targetType, notif.targetId, notif.commentId);
if (route) router.push(route);