# Frontend — Risques & vigilance : State

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

---

<a id="risque-erreurs-silencieuses"></a>
## 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

---

<a id="risque-melange-server-client-state"></a>
## 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

---

<a id="risque-api-state-local-ecran"></a>
## 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`/`data` et la stratégie de refresh/invalidation
- Exception acceptable : état purement UI, local et jetable (ex : input de recherche, filtres temporaires non persistés)

---

<a id="risque-catch-silencieux"></a>
## Catch silencieux — erreur inconnue sans feedback utilisateur

### Risques

- Un `catch` qui 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

```typescript
} 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 `catch` doit avoir une branche `else` (ou `default`) qui affiche un feedback utilisateur explicite.
- Contexte technique : React Native / Expo — 09-03-2026

---

<a id="risque-auto-reset-etat-degrade"></a>
## 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 `isReadOnly` ou `isDegraded`
- 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 `GET` et `HEAD` de 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

---

<a id="risque-refresh-store-fire-and-forget"></a>
## 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

- `await` explicite 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

---

<a id="risque-boolean-ui-hardcode-store"></a>
## État booléen UI dérivé hardcodé au lieu d'être calculé depuis le store

### Risques

- Un état toggle (`isBookmarked`, `isLiked`, `isFollowed`) initialisé à `false` en 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

```typescript
// ❌ 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

---

<a id="risque-flag-isloading-unique-nature-differente"></a>
## 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

```typescript
// ❌ 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

---

<a id="risque-zustand-optimistic-update-sous-listes"></a>
## 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 dans `state.pinnedThreads` ou `state.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

```typescript
// ❌ 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 pas `pinnedThreads`)

- Contexte technique : React Native / Zustand — app-alexandrie 23-03-2026

---

<a id="risque-zustand-erreur-sans-rethrow"></a>
## 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

```typescript
// ❌ 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

---

<a id="risque-catch-objet-throw-vs-error"></a>
## Catch Zustand : objet structuré throwé vs instance `Error`

### Risques

- Quand `apiRequest` propage une erreur HTTP en throwant le JSON brut `{ error: { code, message } }`, un catch limité à `err instanceof Error` laisse 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.message` undefined alors que `(err as ApiError).error.message` contient la cause réelle

### Bonnes pratiques / mitigations

```typescript
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 `apiRequest` directement doit inspecter les deux cas — `Error` natif et objet structuré `{ error: { message } }`
- Contexte technique : React Native / Zustand — app-alexandrie review 5.2, 27-03-2026

---

<a id="risque-flag-global-actions-paralleles"></a>
## Flag booléen global pour des actions par entité — préférer `Set<string>`

### Risques

- Un flag `isLoading: boolean` global 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

```typescript
// ❌ 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'un `boolean` global
- Contexte technique : React Native / Zustand — app-alexandrie review 5.3, 28-03-2026

---

<a id="risque-erreur-partagee-action-liste"></a>
## 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

```typescript
// ❌ 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

---

<a id="risque-emit-vue-mutation-serveur-sans-listener"></a>
## `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 `previousAggregate` consommé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

```bash
# 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 :

1. Invalide les caches locaux dérivés de la même donnée (Map de statuts, computed, props transitives)
2. Recharge la slice agrégée si le parent passe une prop construite à partir d'un autre fetch (`previousAggregate`, dashboard data)
3. **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

```vue
<!-- ❌ 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

---

<a id="risque-templates-vue-references-orphelines"></a>
## 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 avec `tsc --noEmit` seul (Volar moins strict que `tsc` pur sur les expressions template)
- Le composant crashe **uniquement au runtime** : `Cannot read properties of undefined` ou `[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"** :

```bash
# 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]` ou `ReferenceError`
- tester au moins un workflow complet par section refactorée

- Contexte technique : Vue 3 / Volar / `vue-tsc` — RL799_V2 29-04-2026

---

<a id="risque-symboles-orphelins-suppression-bloc"></a>
## 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 defined` au 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+R` sur la page

### Bonnes pratiques / mitigations

```bash
# 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

---

<a id="risque-extraction-vue-ts-bug-typage-latent"></a>
## 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 que `tsc` strict pur. Un handler peut compiler dans le `.vue` si le runtime n'utilise pas le champ manquant
- Le composable extrait est compilé par `tsc -p tsconfig.typecheck.json` **hors 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 :

1. **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
2. **Le `.vue` exploitait un cast implicite** : `v-if="x.foo"` réduit le union type. Dans un `.ts` extrait, narrow explicitement avec un `if` ou un type guard
3. **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** :

1. **NE PAS** mettre `as Foo` pour faire taire le compilateur — c'est probablement masquer le même bug sous un autre nom
2. Identifier lequel des deux types est correct (généralement celui que la fonction service / l'API renvoie réellement)
3. Aligner la signature du handler/composable sur ce type-là
4. 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)
5. 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

---

<a id="risque-fallback-catch-all-mapping-statut-db-ui"></a>
## Fallback catch-all dans le mapping statut DB → UI

### Risques

- Un mapping statut DB → statut UI avec un `return` catch-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 comme `processing`) 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

```ts
// ❌ 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 `if` dans le mapping. Éviter le `return` catch-all final qui absorbe les valeurs non prévues.

---

<a id="risque-fetch-reset-early-return-isloading"></a>
## Race `fetchEntity(reset=true)` avalé par un early-return `isLoading`

### Risques

- Une action `fetchEntity(reset)` qui pose `if (isLoading) return` avale 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.

```ts
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

---

<a id="risque-useref-undefined-hook-reactif-store"></a>
## `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 à `undefined` au 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

```typescript
// ❌ 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 `useRef` qui 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 `onRefresh` n'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

---

<a id="risque-logique-metier-dispersee-callsites"></a>
## 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 de `mode={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

```bash
grep -rn '<PaywallModal mode="subscribe"' apps/mobile/src   # 5 résultats hardcodés → devrait être 0
```

1. Définir un sélecteur pur dans le store : `selectPaywallMode(state): 'trial-upgrade' | 'subscribe'`
2. Le tester isolément (fonction pure, trivial)
3. 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

---

<a id="risque-useeffect-data-length-zero-refetch-infini"></a>
## `useEffect` avec condition `data.length === 0` → re-fetch infini si l'API retourne `[]`

### Risques

- Un `useEffect` qui déclenche un fetch tant que `data.length === 0` boucle 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

```ts
// ❌ 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 === 0` dans des conditions `useEffect`
- Contexte technique : React Native — app-alexandrie code review ia-v2-6 (`topics.tsx`), 28-05-2026

---

<a id="risque-flag-optimiste-ecrase-par-hydratation"></a>
## 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

1. `await` le POST avant `router.replace` (recommandé pour les actions one-shot)
2. ou stocker un pending-flag local (AsyncStorage) à rejouer au prochain online
3. 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

---

<a id="risque-mutation-detail-non-propagee-liste"></a>
## Mutation d'un détail non propagée à la liste (cache stale)

### Risques

- Un store maintient `items: T[]` (liste) ET `currentDetail: T | null` (détail) ; une action mute `currentDetail` mais 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

```typescript
// ✅ 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),
  }));
}
```

1. 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)
2. Les mettre à jour dans le même `set()` — ou documenter pourquoi une surface n'est pas propagée (re-fetch systématique au mount)
3. **Symétrie obligatoire** : si `markCompleted` propage, `resetConsumption` doit propager aussi
4. Test : asserter que `items[i]` et `currentDetail` sont 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

---

<a id="risque-latch-sans-reset-changement-session"></a>
## Latch de chargement sans `reset()` → données figées au changement de session

### Risques

- Un latch anti-boucle (`hasLoadedOnce`, posé `true` en succès ET en erreur pour stopper le refetch sur liste vide) survit au changement de compte
- Après un logout→login à chaud, le `useEffect` gardé 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 un `initialState` factorisé) ET le déclencher sur transition du token d'auth (ref `previousToken` au 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

---

<a id="risque-key-index-liste-editable-inputs"></a>
## `:key` par index sur une liste d'inputs éditable au milieu → désalignement de l'état natif

### Risques

- Un `v-for`/`map` rendant 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 HTML5 `required`

### 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 sur `item.id` avec `v-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

---

<a id="risque-optimistic-update-slice-absente"></a>
## 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 qui `return` sans 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'au `fetchFirstPage`

### Symptômes

- Trou visuel (message envoyé absent) pendant la latence réseau, comblé seulement au fetch suivant

### Bonnes pratiques / mitigations

```typescript
// ✅ 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
