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>
36 KiB
Frontend — Risques & vigilance : State
Extrait de la base de connaissance Lead_tech. Voir
knowledge/frontend/risques/README.mdpour l'index complet.
Erreurs silencieuses / écrans blancs
Risques
- Exceptions non gérées → app inutilisable
- États async mal gérés → UI incohérente (loading infini, vide incompris)
Symptômes
- Écran blanc après une action
- Toast générique "Une erreur est survenue" sans corrélation
- Pas de moyen de reproduire / diagnostiquer
Bonnes pratiques / mitigations
- Pattern "états UI explicites" (loading/empty/error)
- Boundary d'erreur UI + fallback
- Logging minimal côté client avec requestId/traceId quand possible
Mélange server state / client state
Risques
- Cache pollué par des états UI (onglets, filtres)
- UI qui reflète une donnée périmée sans le savoir
- Re-renders et bugs de synchronisation
Symptômes
- "Ça revient tout seul" après refresh
- Données affichées ≠ données du backend
- Debug très long car état implicite
Bonnes pratiques / mitigations
- Séparer explicitement server state vs client state
- Invalidation/reload explicite du server state
- État UI local réinitialisable
Appels API gérés en state local d'écran (refactor coûteux)
Risques
- Server state non partageable entre écrans (liste/detail, wizard, tabs) → duplication et incohérences
- Pas de cache / invalidation standard → bugs subtils et re-fetchs inutiles
- Refactor tardif quand l'epic s'étend (mutations, cache, offline, pagination)
Symptômes
- Même appel API recopié dans plusieurs écrans
- Un écran "A" modifie une ressource mais l'écran "B" n'est jamais rafraîchi
- Code review qui force un refactor vers un store/cache au milieu d'un epic
Bonnes pratiques / mitigations
- Par défaut : créer un store de domaine (ex : Zustand) ou un cache de server state pour tout domaine susceptible d'être réutilisé
- Centraliser
isLoading/error/dataet la stratégie de refresh/invalidation - Exception acceptable : état purement UI, local et jetable (ex : input de recherche, filtres temporaires non persistés)
Catch silencieux — erreur inconnue sans feedback utilisateur
Risques
- Un
catchqui ne traite que les cas connus laisse l'utilisateur face à un spinner qui disparaît sans message - L'état d'erreur reste implicite → impossible de diagnostiquer ou de reproduire
Symptômes
- Bouton spinner qui s'arrête, rien ne se passe
- Pas de toast / message d'erreur affiché
- Erreur "avalée" silencieusement dans les logs
Bonnes pratiques / mitigations
} catch (err: unknown) {
const code = (err as { code?: string }).code;
if (code === 'SUBSCRIPTION_REQUIRED') {
setSubscriptionRequired(true);
} else {
setError('Une erreur est survenue. Veuillez réessayer.'); // toujours un fallback
}
}
- Règle : tout
catchdoit avoir une brancheelse(oudefault) qui affiche un feedback utilisateur explicite. - Contexte technique : React Native / Expo — 09-03-2026
Auto-reset d'un état dégradé sur toute réponse 2xx
Risques
- Le client sort trop tôt d'un mode dégradé alors que la cause serveur est toujours présente
- Le bandeau ou l'état read-only clignote puis disparaît à tort
- Les utilisateurs retentent une action d'écriture qui va encore échouer
Symptômes
- Un GET réussi réinitialise
isReadOnlyouisDegraded - L'UI redevient "normale" alors que Redis ou un service critique est toujours indisponible
- Les erreurs reviennent immédiatement à la prochaine mutation
Bonnes pratiques / mitigations
- Ne réinitialiser l'état dégradé qu'après une requête d'écriture réussie
- Exclure
GETetHEADde la logique de reset - Conserver le mode dégradé tant qu'aucune mutation n'a prouvé le retour à la normale
- Contexte technique : React Native / Expo — 10-03-2026
Refresh store en fire-and-forget après mutation
Risques
- L'UI affiche un succès alors que la resynchronisation a échoué
- État local incohérent avec l'état serveur
- Erreurs silencieuses impossibles à diagnostiquer
Symptômes
- Mutation réussie puis store jamais rafraîchi
- Spinner coupé avant que l'écran soit réellement à jour
- Données anciennes qui persistent jusqu'au prochain reload
Bonnes pratiques / mitigations
awaitexplicite du refresh si l'UI dépend du résultat- Gestion d'erreur dédiée sur la phase de resynchronisation
- N'utiliser le fire-and-forget que pour un effet secondaire réellement non bloquant
- Contexte technique : React Native / Expo — 10-03-2026
État booléen UI dérivé hardcodé au lieu d'être calculé depuis le store
Risques
- Un état toggle (
isBookmarked,isLiked,isFollowed) initialisé àfalseen dur ne reflète jamais l'état réel - Le bouton est toujours en mode "ajouter" sans jamais passer en mode "supprimer"
Symptômes
const isBookmarked = false; // état local géré ci-dessous via state- Bouton bookmark/like toujours dans le même état visuel peu importe l'état réel
Bonnes pratiques / mitigations
// ❌ Anti-pattern — état hardcodé
const isBookmarked = false;
// ✅ Pattern correct — dérivé du store au rendu
const { bookmarks } = useCommunityStore();
const isBookmarked = bookmarks.some((b) => b.thread.id === threadId);
-
Règle : si le store contient la liste (bookmarks, likes, follows), l'état booléen se dérive avec
.some()ou.has() -
Contexte technique : React Native / Zustand — app-alexandrie story 4.4, 20-03-2026
Flag isLoading unique pour des opérations de natures différentes
Risques
- Un même flag (ex:
isBookmarking) utilisé à la fois pour les mutations (add/remove) et le chargement de la liste provoque des bugs visuels — spinner manquant au premier chargement si une mutation est en cours en parallèle
Symptômes
- Spinner absent au premier chargement de la liste bookmarks
- Bouton "ajouter" désactivé alors qu'aucune mutation n'est en cours
Bonnes pratiques / mitigations
// ❌ Anti-pattern — un seul flag pour tout
isBookmarking: boolean;
// ✅ Pattern correct — séparation claire
isBookmarking: boolean; // mutations add/remove
isLoadingBookmarks: boolean; // chargement de la liste (GET)
- Contexte technique : React Native / Zustand — app-alexandrie story 4.4, 20-03-2026
Zustand : optimistic update sur item absent de la liste principale
Risques
- Une action admin qui cherche l'item uniquement dans
state.threads(liste paginée principale) manque les items présents exclusivement dansstate.pinnedThreadsoustate.showcasedThreads - L'optimistic update ne se reflète pas visuellement même si l'appel API a réussi
Symptômes
- L'item mis à jour par une action admin n'apparaît pas dans la nouvelle sous-liste après l'action
- Bug reproductible uniquement quand l'item est épinglé / en vitrine mais pas dans la page courante du flux principal
Bonnes pratiques / mitigations
// ❌ Anti-pattern : cherche uniquement dans la liste principale paginée
const target = state.threads.find((t) => t.id === threadId);
// → manque les items présents uniquement dans pinnedThreads / showcasedThreads
// ✅ Pattern correct : fallback sur toutes les sous-listes du store
const target =
state.threads.find((t) => t.id === threadId) ??
state.pinnedThreads.find((t) => t.id === threadId) ??
state.showcasedThreads.find((t) => t.id === threadId);
-
Règle : toute action qui opère sur un item pouvant être présent dans plusieurs sous-listes doit chercher dans l'ensemble de ces listes
-
Règle complémentaire : ne pas mettre à jour une sous-liste (ex:
pinnedThreads) lors d'une action qui n'y a pas de rapport (ex: mise en vitrine ne touche paspinnedThreads) -
Contexte technique : React Native / Zustand — app-alexandrie 23-03-2026
Store Zustand : méthodes update* qui avalent les erreurs sans rethrow
Risques
- Une méthode store qui catch une erreur sans la relancer (
throw) avale silencieusement les erreurs métier (ex:UNSAFE_LINK) - L'écran appelant ne reçoit jamais l'erreur → impossible d'afficher un feedback à l'utilisateur
Symptômes
- L'action semble réussir côté UI mais la donnée n'a pas changé en base
- Erreurs métier (ex: lien interdit) invisibles pour l'utilisateur final
Bonnes pratiques / mitigations
// ❌ MAUVAIS — l'erreur est avalée, l'écran ne sait pas que ça a échoué
async updateThread(forumSlug, threadId, body) {
await communityService.updateThread(accessToken, forumSlug, threadId, body);
},
// ✅ BON — l'erreur est propagée pour que l'écran puisse réagir
async updateThread(forumSlug, threadId, body) {
try {
await communityService.updateThread(accessToken, forumSlug, threadId, body);
} catch (e) {
const err = e as Error & { code?: string };
throw err; // Le code d'erreur (ex: UNSAFE_LINK) est préservé sur l'objet
}
},
-
Règle : toute méthode store qui appelle le service réseau doit soit (1) relancer l'erreur enrichie avec
throw err, soit (2) la stocker dans le state (set({ error: err.message })). Jamais les deux à la fois sans rethrow si l'écran doit réagir au catch. -
Contexte technique : React Native / Zustand — app-alexandrie 24-03-2026
Catch Zustand : objet structuré throwé vs instance Error
Risques
- Quand
apiRequestpropage une erreur HTTP en throwant le JSON brut{ error: { code, message } }, un catch limité àerr instanceof Errorlaisse le message inaccessible → fallback générique ou message vide affiché
Symptômes
- Toast "Erreur de connexion au serveur" affiché même quand le serveur retourne un message explicite
err.messageundefined alors que(err as ApiError).error.messagecontient la cause réelle
Bonnes pratiques / mitigations
catch (err: unknown) {
let message = 'Erreur de connexion au serveur.';
if (err instanceof Error) {
message = err.message;
} else if (
typeof err === 'object' && err !== null &&
'error' in err &&
typeof (err as { error: { message?: string } }).error?.message === 'string'
) {
message = (err as { error: { message: string } }).error.message;
}
set({ error: message, isLoading: false });
}
- Règle : tout store qui appelle
apiRequestdirectement doit inspecter les deux cas —Errornatif et objet structuré{ error: { message } } - Contexte technique : React Native / Zustand — app-alexandrie review 5.2, 27-03-2026
Flag booléen global pour des actions par entité — préférer Set<string>
Risques
- Un flag
isLoading: booleanglobal pour une action par item (follow, like, bookmark sur une liste) désactive tous les boutons dès qu'une action est en cours sur un seul item
Symptômes
- Cliquer "Suivre" sur une carte désactive tous les autres boutons "Suivre" de la liste
- Impossible de déclencher deux actions en parallèle sur des entités différentes
Bonnes pratiques / mitigations
// ❌ Anti-pattern — un seul flag pour toutes les instances
followIsLoading: boolean;
// ✅ Pattern correct — un Set des IDs en cours
followingInProgress: Set<string>;
// Selector
isFollowInProgress: (userId: string) => get().followingInProgress.has(userId),
// Mutation
set((state) => {
const next = new Set(state.followingInProgress);
next.add(targetUserId);
return { followingInProgress: next };
});
// Après succès/erreur
next.delete(targetUserId);
- Règle : toute action async sur des items d'une liste (like, bookmark, follow, reaction) doit utiliser
Set<EntityId>au lieu d'unbooleanglobal - Contexte technique : React Native / Zustand — app-alexandrie review 5.3, 28-03-2026
Clé d'erreur partagée entre action et liste dans un store Zustand
Risques
- Une clé d'erreur partagée entre une mutation (follow/unfollow) et un fetch de liste (followers, followings) affiche une erreur d'action périmée comme erreur de chargement
- Ex :
followError: 'ALREADY_FOLLOWING'stocké depuis une action précédente s'affiche dans l'écran "Abonnés" comme "Erreur de chargement"
Symptômes
- Écran de liste affiche une erreur après une action précédente sans rapport direct
- Bug intermittent difficile à reproduire, corrélé à l'ordre des actions utilisateur
Bonnes pratiques / mitigations
// ❌ Anti-pattern — clé partagée
followError: string | null;
// ✅ Pattern correct — clés séparées par nature
followError: string | null; // erreur de followUser/unfollowUser
followersError: string | null; // erreur de fetchFollowers
followingsError: string | null; // erreur de fetchFollowings
- Règle : dans un store qui gère à la fois des mutations et des listes paginées, chaque opération doit avoir sa propre clé d'erreur
- Contexte technique : React Native / Zustand — app-alexandrie review 5.3, 28-03-2026
emit Vue annonçant une mutation serveur sans listener parent → caches stale
Risques
- Un composant enfant émet un événement (
emit('approved')) après une mutation côté serveur, mais aucun parent n'écoute - L'enfant met bien à jour son état local, mais les caches parents qui dérivent du même statut (badges accordion, verrous cascade, prop
previousAggregateconsommée par d'autres enfants) restent stale indéfiniment, jusqu'au prochain changement d'écran/route - Bug invisible dans les tests structurels (
content.includes) et passe inaperçu en revue parce que le code enfant est "correct"
Symptômes
- Tout autre affichage du même statut dans le parent ou dans des sous-composants frères reste "Publiée" alors que la planche est "Approuvée" en DB
- L'UI ne se rafraîchit qu'au prochain reload manuel ou navigation
Bonnes pratiques / mitigations
# Repérer les emits qui annoncent une mutation serveur
grep -rn "defineEmits" apps/frontend/src/components
# Pour chaque emit trouvé, chercher s'il a au moins un listener parent
grep -rn "@<eventName>=" apps/frontend/src/pages apps/frontend/src/components
Zéro listener = bug latent (sauf si l'emit est purement informatif — analytics, debug).
Règle : tout emit qui annonce une mutation persistée serveur (création, suppression, changement de statut, validation) doit avoir au moins un listener parent qui :
- Invalide les caches locaux dérivés de la même donnée (Map de statuts, computed, props transitives)
- Recharge la slice agrégée si le parent passe une prop construite à partir d'un autre fetch (
previousAggregate, dashboard data) - Ne se contente PAS de l'optimistic update de l'enfant — la source de vérité reste serveur, le cache parent doit refléter l'état serveur post-mutation
<!-- ❌ Enfant émet, parent ignore. Le badge de l'accordion reste stale -->
<PlancheTraceeCard :tenue-id="planche.tenueId" mode="previous" />
<!-- ✅ Listener explicite qui invalide les caches dérivés -->
<PlancheTraceeCard
:tenue-id="planche.tenueId"
mode="previous"
@approved="onPlancheApproved(planche.tenueId)"
/>
Couverture : test de mount complet via @vue/test-utils qui simule l'événement et vérifie que le rendu parent change. Les tests readFileSync + content.includes('emit') valident que le code enfant émet, pas que le parent écoute.
- Contexte technique : Vue 3 — RL799_V2 29-04-2026
Templates Vue — références orphelines invisibles à tsc --noEmit
Risques
- Une variable supprimée du
<script setup>mais encore référencée dans le<template>ne génère pas d'erreur compile avectsc --noEmitseul (Volar moins strict quetscpur sur les expressions template) - Le composant crashe uniquement au runtime :
Cannot read properties of undefinedou[Vue warn] Property "X" was accessed during render but is not defined
Symptômes
- Refactor où on extrait un composable et oublie de destructurer une variable, ou on retire un import devenu (apparemment) inutilisé
- Typecheck passe, tests structurels passent, page charge mais une section ne s'affiche pas / un bouton ne fait rien
[Vue warn]dans la console au mount
Bonnes pratiques / mitigations
Recommandation outillage : migrer le typecheck du projet de tsc --noEmit vers vue-tsc -p tsconfig.typecheck.json --noEmit. Avec vue-tsc, les expressions template sont strictement typées contre le <script setup> exposé. Une ref orpheline → erreur de compile, pas warning runtime.
Checklist étendue avant de marquer une extraction "done" :
# Pour chaque symbole supprimé/non destructuré, grep dans le template
for symbol in normalizedQuery directoryOfficeLabel; do
echo "=== $symbol ==="
# Patterns template critiques
grep -nE "v-(if|else-if|show)=\"[^\"]*\\b$symbol\\b" apps/frontend/src/pages/<Page>.vue
grep -nE "\\{\\{[^}]*\\b$symbol\\b" apps/frontend/src/pages/<Page>.vue
grep -nE "(:|@)[a-z-]+=\"[^\"]*\\b$symbol\\b" apps/frontend/src/pages/<Page>.vue
done
QA visuel obligatoire post-refactor : pour tout refactor qui touche une page importante, ouvrir la page en browser dev avant de pousser :
-
naviguer aux états critiques (search, modals, toggle accordion)
-
vérifier la console : zéro
[Vue warn]ouReferenceError -
tester au moins un workflow complet par section refactorée
-
Contexte technique : Vue 3 / Volar /
vue-tsc— RL799_V2 29-04-2026
Symboles orphelins après suppression d'un bloc — checklist grep
Risques
- Refactor où on supprime un bloc cohérent (state + computeds + handlers) en utilisant un Edit ciblé. Tout compile, tous les tests passent, mais au runtime :
ReferenceError: <symbole> is not definedau mount - Le symbole supprimé était encore référencé dans un lifecycle hook (
onMounted,onUnmounted), un watcher, ou un handler asynchrone non inclus dans le bloc supprimé
Symptômes
- Page qui ne mount plus après refactor, alors que typecheck OK + tests verts
- Erreur visible uniquement à
cmd+Rsur la page
Bonnes pratiques / mitigations
# Pour chaque symbole déclaré dans le bloc à supprimer
for symbol in updatePointerType pointerMql closeInsertMenuOnOutsideClick; do
echo "=== $symbol ==="
grep -n "\\b$symbol\\b" apps/frontend/src/pages/<Page>.vue
done
Doit retourner uniquement les lignes du bloc à supprimer. Si une référence apparaît hors du bloc → ne pas supprimer sans traiter explicitement (déplacer, adapter, ou laisser).
Lieux à vérifier en priorité :
| Lieu | Fréquence du piège |
|---|---|
onMounted(() => { ... }) |
⚠️⚠️⚠️ très fréquent |
onUnmounted(() => { ... }) |
⚠️⚠️⚠️ très fréquent (cleanup) |
watch(() => x, () => { fn() }) |
⚠️⚠️ fréquent |
Handler async (.then(() => fn())) |
⚠️ rare mais existe |
| Computed dans une autre section du fichier | ⚠️ rare |
Template (@click, :disabled) |
typecheck attrape via SFC plugin |
Garde-fou complémentaire : QA visuel obligatoire post-refactor (cf. risque-templates-vue-references-orphelines).
- Contexte technique : Vue 3 — RL799_V2 29-04-2026
Extraction .vue → composable .ts révèle des bugs de typage latents
Risques
- Vue 3 + Volar compile les blocs
<script setup>avec une stratégie d'inférence moins agressive quetscstrict pur. Un handler peut compiler dans le.vuesi le runtime n'utilise pas le champ manquant - Le composable extrait est compilé par
tsc -p tsconfig.typecheck.jsonhors contexte Vue → toutes les règles strictes s'appliquent → la divergence de type devient une erreur bloquante - C'est un effet de bord positif mais qui peut bloquer l'extraction tant qu'on n'a pas diagnostiqué la divergence
Symptômes
Type 'X' is not assignable to type 'Y'.
The types of '<champ>.<sous-champ>' are incompatible…
Type 'A' is missing the following properties from type 'B': …
Cas typique : un emit Vue annonçait Detail (type pour la liste) alors que la fonction service renvoyait Data (type pour la mutation). Silencieux dans le .vue d'origine, devenu visible dans le .ts extrait.
Bonnes pratiques / mitigations
Trois cas typiques quand l'erreur apparaît après extraction :
- Le type annoncé ne matche pas le type réellement émis : aligner sur le type réellement émis plutôt que sur le type annoncé dans
defineEmits. Si possible, corriger aussi la signature de l'émetteur — mais c'est un autre scope - Le
.vueexploitait un cast implicite :v-if="x.foo"réduit le union type. Dans un.tsextrait, narrow explicitement avec unifou un type guard - Volar n'analysait pas un chemin de type complexe : type récursif, génériques imbriqués,
Pick<...>dans une union → extraire un alias intermédiaire propre dans@<module>/types.ts
Quoi faire face à l'erreur :
- NE PAS mettre
as Foopour faire taire le compilateur — c'est probablement masquer le même bug sous un autre nom - Identifier lequel des deux types est correct (généralement celui que la fonction service / l'API renvoie réellement)
- Aligner la signature du handler/composable sur ce type-là
- Documenter dans un commentaire au-dessus du handler que le type émis diverge du type annoncé dans
defineEmits(si on ne corrige pas l'émetteur dans le même refactor) - Ouvrir un TODO si la correction de l'émetteur est hors scope
Recommandation outillage : vue-tsc plutôt que tsc pur en typecheck (cf. risque-templates-vue-references-orphelines). Ce genre de divergence aurait été détecté avant le refactor.
- Contexte technique : Vue 3 / Volar /
vue-tsc— RL799_V2 29-04-2026
Fallback catch-all dans le mapping statut DB → UI
Risques
- Un mapping statut DB → statut UI avec un
returncatch-all final accepte implicitement toute nouvelle valeur d'enum DB sans alerte - Une nouvelle valeur (ex :
pending) atterrit alors dans la branche par défaut (ex : rendue commeprocessing) avec un libellé incorrect, violant silencieusement le contrat d'affichage
Symptômes
- Une valeur d'enum DB non explicitement gérée affiche le mauvais état UI (ex : "En cours…" au lieu de "En attente")
- L'ajout d'une valeur d'enum côté DB ne déclenche aucune erreur de compile ni de test ; le mauvais libellé passe inaperçu
Bonnes pratiques / mitigations
// ❌ Le catch-all masque silencieusement "pending" sous "processing"
function dbStatusToUiStatus(s: DBStatus): UIStatus {
if (s === "ready") return "ready";
if (s === "failed") return "failed";
return "processing"; // "pending" atterrit ici sans libellé correct
}
// ✅ Mapping exhaustif : un if par valeur d'enum
function dbStatusToUiStatus(s: DBStatus): UIStatus {
if (s === "ready") return "ready";
if (s === "failed") return "failed";
if (s === "pending") return "pending";
return "processing";
}
- Règle : chaque valeur de l'enum DB doit avoir son propre
ifdans le mapping. Éviter lereturncatch-all final qui absorbe les valeurs non prévues.
Race fetchEntity(reset=true) avalé par un early-return isLoading
Risques
- Une action
fetchEntity(reset)qui poseif (isLoading) returnavale le 2ᵉ appel lorsqu'un changement de filtre/critère survient pendant un fetch en vol - La promesse du 1er fetch (filtre A) résout après le changement et écrase le state avec les résultats de A, alors que l'UI affiche le filtre B
Symptômes
- 100 % reproductible sur connexion lente : résultats incohérents après un changement rapide de filtre/recherche/onglet
- L'écran affiche le bon filtre sélectionné mais les mauvaises données
Bonnes pratiques / mitigations
Distinguer reset (changement de critère — toujours lancer + ignorer les fetchs périmés) de loadMore (pagination — early-return légitime), via un token incrémental.
fetchEntity: async (token, reset = false) => {
const { isLoading, fetchId } = get();
if (!reset && isLoading) return; // garde seulement pour loadMore
const myId = fetchId + 1;
set({ isLoading: true, fetchId: myId });
try {
const result = await api.getEntity({ filter, ... });
if (get().fetchId !== myId) return; // fetch périmé : drop
set({ ..., isLoading: false });
} catch (err) {
if (get().fetchId !== myId) return;
set({ error: ..., isLoading: false });
}
}
- Test : 1er fetch deferred (résolu à la main), changement de filtre + 2e fetch, vérifier que le résultat tardif du 1er ne pollue pas le store
- Lien : variante côté pattern dans
patterns/state.md#pattern-race-token-partage-latest-wins - Contexte technique : React Native / Zustand — app-alexandrie review IA-v2.7, 28-05-2026
useRef(undefined) dans un hook réactif à un store persistant → refresh fantôme
Risques
- Un hook qui suit la dernière valeur observée d'un store via
useRef<T | undefined>(undefined)rejoue son effet à chaque remount si le store a été touché entre-temps - Le ref repart à
undefinedau remount, mais le store conserve sa valeur ≠ undefined → l'effet se redéclenche sans action utilisateur
Symptômes
- Refresh fantôme au retour sur un écran après navigation (flush UI local, requête réseau inutile, scroll-top non voulu)
Bonnes pratiques / mitigations
// ❌ confond "1er mount d'une session" et "remount session déjà en cours"
const lastSeenRef = useRef<number | undefined>(undefined);
// ✅ initialiser à la valeur courante du store → ne déclenche que sur vraie transition
const lastSeenRef = useRef<number | undefined>(refreshTimestamp);
- Règle : un
useRefqui mémorise la dernière valeur observée d'un store doit être initialisé à la valeur courante, pas àundefined - Test : mount → store fire → unmount → remount → vérifier que
onRefreshn'est PAS rappelé tant que la valeur n'a pas changé - Lien : pattern associé
patterns/state.md#pattern-event-bus-zustand-timestamp - Contexte technique : React Native / Zustand — app-alexandrie review IA-v2.8 (H2), 28-05-2026
Logique métier dispersée dans les callsites au lieu d'un sélecteur dérivé partagé
Risques
- Une prop dérivable d'un store global est recalculée dans chaque callsite (
mode="subscribe"hardcodé au lieu demode={trial.active ? 'trial-upgrade' : 'subscribe'}) - N callsites sur M oublient la branche conditionnelle → wording/mode incohérent. Ni TypeScript ni les tests unitaires ne capturent ce manque si chaque callsite n'est pas testé
Symptômes
- Comportement correct sur certains écrans, incorrect sur d'autres rendant le même composant
Bonnes pratiques / mitigations
grep -rn '<PaywallModal mode="subscribe"' apps/mobile/src # 5 résultats hardcodés → devrait être 0
- Définir un sélecteur pur dans le store :
selectPaywallMode(state): 'trial-upgrade' | 'subscribe' - Le tester isolément (fonction pure, trivial)
- Migrer tous les callsites vers
useStore(selectPaywallMode)
- Règle : toute prop dérivable du store doit l'être via un sélecteur exporté, pas recalculée par callsite (le typage TS ne protège pas → la revue humaine reste indispensable)
- Contexte technique : React Native / Zustand — app-alexandrie review IA-v2.5 (H3), 27-05-2026
useEffect avec condition data.length === 0 → re-fetch infini si l'API retourne []
Risques
- Un
useEffectqui déclenche un fetch tant quedata.length === 0boucle indéfiniment quand l'API retourne légitimement un tableau vide - Insidieux : passe les tests mockés (qui renvoient des données), ne se manifeste qu'avec un serveur réel renvoyant
[]
Symptômes
- Fetch en boucle à chaque re-render/remount sur un endpoint vide
Bonnes pratiques / mitigations
// ❌ data.length reste 0 si l'API renvoie [] → fetch en boucle
useEffect(() => {
if (data.length === 0) void fetchData();
}, [data.length, fetchData]);
// ✅ flag single-shot (ref local ou flag `attempted` dérivé du store)
const hasFetchedRef = useRef(false);
useEffect(() => {
if (data.length === 0 && !hasFetchedRef.current) {
hasFetchedRef.current = true;
void fetchData();
}
}, [data.length, fetchData]);
- Détection : grep
length === 0dans des conditionsuseEffect - Contexte technique : React Native — app-alexandrie code review ia-v2-6 (
topics.tsx), 28-05-2026
Flag local optimiste + endpoint d'hydratation = écrasement silencieux si le POST a échoué
Risques
- Un flag basculé en optimiste (
markFlagLocal()) puis persisté en fire-and-forget (void persistToBackend()) est ramené à la valeur backend au prochain endpoint d'hydratation si le POST a échoué (offline, 500) - L'utilisateur perd l'optimistic update sans notification (ex : revoit l'onboarding qu'il avait "passé")
Symptômes
- Action one-shot (skip onboarding, marquage lu) qui "revient" après un reboot offline
Bonnes pratiques / mitigations
awaitle POST avantrouter.replace(recommandé pour les actions one-shot)- ou stocker un pending-flag local (AsyncStorage) à rejouer au prochain online
- ou ne basculer le flag local qu'au succès du POST (perd la réactivité)
- Règle : tout flag local optimiste ré-hydraté depuis le backend doit avoir une stratégie de réconciliation explicite, pas un
void promise.catch(() => undefined) - Contexte technique : React Native / Zustand — app-alexandrie ia-v2-6, 28-05-2026
Mutation d'un détail non propagée à la liste (cache stale)
Risques
- Un store maintient
items: T[](liste) ETcurrentDetail: T | null(détail) ; une action mutecurrentDetailmais oublie de propager àitems[]et aux listes secondaires - L'invariant "source de vérité unique" est respecté côté backend (les endpoints lisent la même table) mais bafoué côté store (champs dénormalisés)
- Invalider un compteur global (
progression: null) ne suffit pas :items[]est un cache local qui survit au retour à la liste
Symptômes
- L'utilisateur agit sur un détail puis revient à la liste et voit l'ancien état
- Bug souvent asymétrique (sens A→liste propagé, B→liste oublié) → invisible jusqu'au 1er QA device
Bonnes pratiques / mitigations
// ✅ Propager le même fait à TOUTES les surfaces qui le dénormalisent
async resetDetailConsumption(token) {
await api.reset(currentDetail.id);
const resetId = currentDetail.id;
set((state) => ({
progression: null,
currentDetail: { ...state.currentDetail, consumptionState: 'NOT_STARTED', completedAt: null },
items: state.items.map((i) =>
i.id === resetId ? { ...i, consumptionState: 'NOT_STARTED', completedAt: null } : i),
packContents: state.packContents.map((i) =>
i.id === resetId ? { ...i, consumptionState: 'NOT_STARTED', completedAt: null } : i),
}));
}
- Avant d'écrire l'action, lister toutes les surfaces du state qui partagent un champ avec l'entité mutée (liste principale, listes secondaires, caches paginés)
- Les mettre à jour dans le même
set()— ou documenter pourquoi une surface n'est pas propagée (re-fetch systématique au mount) - Symétrie obligatoire : si
markCompletedpropage,resetConsumptiondoit propager aussi - Test : asserter que
items[i]etcurrentDetailsont alignés après chaque mutation
- Note : rencontré 2× sur app-alexandrie (ux-cleanup-6 backend, puis ux-cleanup-7 store mobile) — la leçon a glissé d'une couche à l'autre
- Contexte technique : React Native / Zustand — app-alexandrie ux-cleanup-7, 29-05-2026
Latch de chargement sans reset() → données figées au changement de session
Risques
- Un latch anti-boucle (
hasLoadedOnce, posétrueen succès ET en erreur pour stopper le refetch sur liste vide) survit au changement de compte - Après un logout→login à chaud, le
useEffectgardé par le latch ne refetch jamais → l'écran reste figé sur les données du compte précédent - Divergence entre stores frères : l'un (messaging) a son reset, l'autre (notifications) ne l'a pas
Symptômes
- Données de l'ancien compte affichées après changement de session sans rechargement complet de l'app
Bonnes pratiques / mitigations
- Règle « latch ⇒ reset » : tout store introduisant un latch de chargement DOIT fournir
reset()(via uninitialStatefactorisé) ET le déclencher sur transition du token d'auth (refpreviousTokenau niveau écran) ou au logout - Vérifier la symétrie entre stores frères : si A reset, B doit reset
- Contexte technique : React Native / Zustand — app-alexandrie review bo-4, 04-06-2026
:key par index sur une liste d'inputs éditable au milieu → désalignement de l'état natif
Risques
- Un
v-for/maprendant des<input>dont l'utilisateur peut retirer un élément du milieu, keyé sur l'index, recycle le nœud DOM de l'ancien index pour la nouvelle donnée - La valeur contrôlée (
v-model) se patche bien, mais tout l'état NON contrôlé suit l'ancien nœud : focus, position curseur, sélection, bulle de validation HTML5required
Symptômes
- On retire une ligne au-dessus d'un champ en cours d'édition → focus/validation restent sur le mauvais champ
Bonnes pratiques / mitigations
- Transformer la liste en tableau d'objets
{ id, value }(id stable généré à l'ajout) et keyer suritem.idavecv-model="item.value" - Vrai pour toute liste réordonnable ou supprimable au milieu
- Contexte technique : Vue 3 — RL799 (
InstructionForm.vue), code review adversariale, 13-06-2026
Optimistic update sur slice indexée absente → message perdu visuellement
Risques
- Dans un store structuré en
Record<id, slice>(chaque slice = items + pagination + flags), un optimistic update quireturnsans rien faire quand la slice n'existe pas encore perd visuellement l'item - Cas typique : "Nouveau message → premier envoi → navigation immédiate vers
/messages/:id" — la slice n'est jamais créée avant la navigation, l'item disparaît jusqu'aufetchFirstPage
Symptômes
- Trou visuel (message envoyé absent) pendant la latence réseau, comblé seulement au fetch suivant
Bonnes pratiques / mitigations
// ✅ initialiser la slice si absente lors de l'optimistic update
const slice = state.messagesByConversationId[message.conversationId];
const nextSlice = slice
? { ...slice, items: [message, ...slice.items] }
: { items: [message], nextCursor: null, hasMore: false, isReadOnly: false, isLoading: false, error: null };
-
Règle : tout store en
Record<id, slice>DOIT initialiser la slice manquante lors d'un optimistic update (sinon le fetch suivant l'écrase, mais l'utilisateur voit un trou) -
Lien : voisin de
risque-zustand-optimistic-update-sous-listes(recherche d'item à travers les sous-listes) -
Contexte technique : React Native / Zustand — app-alexandrie rétro Epic 10 (A2), 13-05-2026
-
Contexte technique : frontend / mapping statut DB → UI — app-template-resto 25-06-2026