# Frontend — Patterns : State

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

---

<a id="pattern-etats-ui-loading-empty-error"></a>
## Pattern : Gestion explicite des états UI (loading / empty / error)

### Synthèse

- **Objectif** : éviter les interfaces ambiguës ou incohérentes en rendant explicites tous les états possibles d'une vue.
- **Contexte** : SPA ou webapp consommant des données asynchrones (API, backend, cache).
- **Quand l'utiliser** : dès qu'une vue dépend de données externes ou d'un traitement async.
- **Quand l'éviter** : vues purement statiques ou synchrones sans dépendance externe.

### Analyse

- **Avantages** :
  - UX plus prévisible et compréhensible
  - Debug facilité (état visible = problème identifiable)
  - Base saine pour tests et accessibilité
- **Limites / vigilance** :
  - Peut sembler verbeux sur des écrans simples
  - Nécessite une discipline pour ne pas "court-circuiter" les états

### Validation

- Validé le : 25-01-2026
- Contexte technique : SPA (React / Vue / Svelte agnostique), API HTTP

### Implémentation (exemple minimal)

```txt
if (loading) {
  afficher un skeleton ou spinner
} else if (error) {
  afficher un message clair + action possible
} else if (data est vide) {
  afficher un état empty explicite
} else {
  afficher la vue nominale
}
```

### Checklist

- [ ] Aucun écran blanc ou silencieux
- [ ] Message d'erreur compréhensible pour l'utilisateur
- [ ] États testables individuellement
- [ ] Accessibilité respectée (focus, lecture écran)
- [ ] Pas de logique métier cachée dans le rendu

---

<a id="pattern-separation-server-state-client-state"></a>
## Pattern : Séparation claire server state / client state

### Synthèse

- **Objectif** : éviter le mélange des responsabilités entre données serveur et état local UI.
- **Contexte** : SPA ou webapp consommant une API avec interactions utilisateur.
- **Quand l'utiliser** : dès que l'application affiche des données distantes modifiables ou synchronisées.
- **Quand l'éviter** : applications très simples ou purement statiques.

### Analyse

- **Avantages** :
  - Logique plus lisible et testable
  - Réduction des bugs liés aux états incohérents
  - Évolutivité facilitée quand l'app grossit
- **Limites / vigilance** :
  - Demande de la rigueur dans le découpage
  - Peut sembler abstrait au début pour des petits projets

### Validation

- Validé le : 25-01-2026
- Contexte technique : SPA agnostique (React / Vue / Svelte), API HTTP

### Implémentation (exemple minimal)

```txt
serverState = données venant du backend (fetch, cache, sync)
clientState = état local UI (filtres, onglets, modales, formulaires)

Ne jamais :
- stocker du state UI dans le cache serveur
- dériver la logique UI directement des réponses API sans adaptation
```

### Checklist

- [ ] Les données serveur peuvent être invalidées / rechargées
- [ ] L'état UI est local et réinitialisable
- [ ] Les responsabilités sont lisibles dans le code
- [ ] Les tests peuvent cibler chaque type d'état
- [ ] Pas de dépendance implicite entre UI et API

---

<a id="pattern-refresh-idempotent-liste-paginee"></a>
## Pattern : Refresh idempotent sur store de liste paginée

### Synthèse

- **Objectif** : garantir qu'un pull-to-refresh recharge une liste paginée sans doublons, sans courses réseau et sans état intermédiaire incohérent.
- **Contexte** : app mobile ou SPA avec store de domaine (ex. Zustand) et pagination incrémentale.
- **Quand l'utiliser** : dès qu'une même liste supporte à la fois `loadMore` et `refresh`.
- **Quand l'éviter** : listes purement statiques ou données entièrement remplacées sans pagination.

### Analyse

- **Avantages** :
  - évite les doublons lors des refresh concurrents
  - garde une transition atomique entre ancien et nouvel état
  - rend le comportement async testable côté store
- **Limites / vigilance** :
  - impose une discipline claire entre `refresh` et `loadMore`
  - demande une clé d'identité stable pour dédupliquer les items

### Validation

- Validé le : 10-03-2026
- Contexte technique : React Native / Expo / Zustand / listes paginées

### Implémentation (exemple minimal)

```txt
- conserver une promesse de refresh partagée tant qu'un refresh est en vol
- refuser ou réutiliser tout refresh concurrent au lieu d'en lancer un second
- remplacer atomiquement la liste à la fin du refresh
- dédupliquer les items par identifiant au merge des pages suivantes
- empêcher `loadMore` de fusionner sur un snapshot devenu obsolète
```

### Checklist

- [ ] Une seule promesse de refresh en vol à la fois
- [ ] `refresh` et `loadMore` ont des garde-fous explicites
- [ ] La liste est remplacée atomiquement après refresh
- [ ] Les pages suivantes sont dédupliquées par identifiant stable
- [ ] Tests sur refresh concurrent + refresh suivi de pagination

---

<a id="pattern-ui-admin-legere-domaine-existant"></a>
## Pattern : UI admin légère sur domaine existant

### Synthèse

- **Objectif** : ajouter une capacité interne simple sans ouvrir trop tôt un back-office séparé ni dupliquer la logique métier.
- **Contexte** : app mobile ou SPA avec un domaine métier déjà structuré et quelques actions internes ponctuelles.
- **Quand l'utiliser** : publication, activation, modération légère, bascule de statut, action opérateur simple.
- **Quand l'éviter** : permissions complexes, workflows multiples, audit riche ou volume d'actions qui justifie un vrai espace d'administration.

### Analyse

- **Avantages** :
  - réutilise le service et le store métier existants
  - limite le coût de structure pour une capacité admin mince
  - garde les mutations testables et lisibles
- **Limites / vigilance** :
  - ne pas laisser cette approche dériver vers un pseudo back-office implicite
  - le refresh après mutation doit être explicite sur les vues impactées

### Validation

- Validé le : 10-03-2026
- Contexte technique : React Native / Expo Router / store de domaine

### Implémentation (exemple minimal)

```txt
- ajouter une route dédiée minimale pour l'action interne
- réutiliser le service/store métier existant au lieu de créer une couche parallèle
- afficher le statut courant avant action
- bloquer les actions concurrentes avec un flag explicite (`isUpdating*`)
- déclencher un refresh explicite des vues impactées après succès
- éviter les mutations en fire-and-forget
```

### Checklist

- [ ] Route dédiée minimale, pas de mini back-office générique
- [ ] Réutilisation du domaine métier existant
- [ ] Garde-fou explicite contre les doubles actions
- [ ] Refresh explicite après mutation réussie
- [ ] Tests sur succès, erreur et action concurrente

---

<a id="pattern-state-hydratation-auth-async"></a>
## Pattern : Hydratation auth asynchrone avec états explicites

### Synthèse
Lors d'un passage sync -> async au boot, exposer un statut d'hydratation explicite (`idle`/`hydrating`/`ready`).

### Analyse
Sans état transitoire formel, les guards lisent des valeurs incomplètes et déclenchent des redirections erronées.

### Validation
- Validé le : 18-04-2026
- Contexte technique : Vue 3 / Pinia / boot async — RL799_V2
- Applicable à toute initialisation critique : session, feature flags, restore état persistant.

### Implémentation (exemple minimal)
- Store : `hydrateStatus` + promesse partagée en cours pour les appels concurrents.
- Guards : `await hydrate()` avant toute décision d'accès.
- UI : fallback de rendu tant que l'hydratation n'est pas `ready`.

---

<a id="pattern-refactor-monolithe-vue-sous-lots"></a>
## Pattern : Refactor monolithe Vue — sous-lots Go/No-Go + ordre topologique

### Synthèse

- **Objectif** : découper un composant Vue monolithique (> 1500 lignes script ou > 2000 lignes total) en composables + sous-composants livrés en commits successifs validés un à un.
- **Contexte** : page Vue avec plusieurs responsabilités métier mêlées, peu ou pas de `describe`, sans sous-découpage interne.
- **Quand l'utiliser** : fichier > 1500 lignes script, fenêtre de calme sans PR concurrente prévue, tests E2E robustes en place.
- **Quand l'éviter** : page < 1000 lignes, pas de tests E2E pour servir de filet, ou planning serré sans Go/No-Go possible entre commits.

### Analyse

- **Avantages** :
  - aucune régression à chaque sous-lot validé indépendamment
  - helpers réutilisables émergent naturellement
  - reporter vitest / Playwright groupe les échecs par responsabilité
- **Limites / vigilance** :
  - les `data-testid` E2E doivent être **copiés-collés exactement** dans les composants enfants (sinon les E2E rotent silencieusement)
  - les bindings template doivent rester alignés avec les noms destructurés du composable
  - le typecheck `tsc --noEmit` ne suffit pas — utiliser `vue-tsc` (cf. `frontend/risques/state.md` risque-templates-vue-references-orphelines)

### Validation

- Validé le : 29-04-2026
- Contexte technique : Vue 3 / Composition API — RL799_V2 (4 pages refactorées, 17 commits, 644/644 tests verts)

### Stratégie en 3 étapes

1. **Audit complet préalable** : sections du template avec plages de lignes + rôle métier + `v-if` clé, blocs script regroupés par responsabilité avec évaluation `autonome` vs `couplé`, imports + composables + DTOs consommés, tests existants, couplages externes (deep-links, sélecteurs CSS).
2. **Plan en sous-lots ordonnés par risque croissant** :
   - L1 : composables purement autonomes (zéro dépendance interne)
   - L2 : composants enfants auto-contenus (modales)
   - L3 : composables avec couplage modéré (cache + watchers)
   - L4 : composables avec couplage métier subtil (cascade, propagation)
   - L5 : composant enfant complexe (D&D, drag-handle conditionnel)
3. **Go/No-Go explicite entre chaque lot** : 1 commit thématique par lot avec validation typecheck + tests + diff montré au pair avant push.

### Ordre topologique des dépendances dans le script post-refactor

Quand plusieurs composables se consomment mutuellement, respecter strictement l'ordre topologique (la déclaration de la donnée doit précéder son usage, sous peine de TDZ) :

```typescript
// 1. Data centrale de la page
const currentSoiree = ref<SoireeData | null>(null);
const error = ref('');

// 2. Composables qui ne consomment que la data centrale
const { lifecycle, isLiveView } = useSoireeLifecycle(currentSoiree, allTenuesCancelled);

// 3. Composables qui produisent des refs consommées par d'autres
const { responsesData } = useResponseTracking(currentSoiree, error);

// 4. Composables qui consomment les refs produites
const { pastActiveGrade } = useGradeSelection(soireeTenues, responsesData);
```

### Anti-patterns

- ❌ Grouper plusieurs préoccupations dans un même composable juste parce qu'elles sont voisines dans le script
- ❌ Sortir un composable qui consomme directement `process.cwd()` ou un store global sans le passer en argument (couplage caché)
- ❌ Extraire le CSS scoped vers le composant enfant **avant** de vérifier que toutes les classes y sont effectivement utilisées (certaines classes vivent en CSS global)
- ❌ Sauter le grep des références orphelines avant de supprimer un bloc

### Checklist

- [ ] `data-testid` copiés-collés exactement dans les composants enfants
- [ ] Bindings template alignés avec les noms destructurés
- [ ] Props/events des composants enfants alignés avec les usages
- [ ] `vue-tsc` (pas `tsc`) en vérification typecheck
- [ ] QA visuel obligatoire post-refactor (mount réel en browser)

---

<a id="pattern-convention-pages-module-scope"></a>
## Pattern : Convention `pages/<module>/{composables,components,utils,__tests__}/`

### Synthèse

- **Objectif** : structurer une app Vue qui dépasse 20 pages avec plusieurs domaines métier, en regroupant la logique extraite par module.
- **Contexte** : app Vue avec routing par page et logique extraite (composables, sous-composants, tests).
- **Quand l'utiliser** : page > 1000 lignes envisage le scope, > 1500 lignes le crée systématiquement.
- **Quand l'éviter** : page < 500 lignes sans logique extraite — laisser au niveau racine.

### Analyse

- **Avantages** :
  - un module métier = un sous-dossier, navigation simplifiée
  - l'alias `@/pages/<module>/<X>` rend les fichiers résilients aux déplacements
  - les tests d'un module vivent avec lui (pas dans un dossier global qui mélange tout)
- **Limites / vigilance** :
  - les tests scopés calculent leur `root` avec **4 niveaux** de remontée (`'../../../..'`), pas 3 — source d'erreur fréquente lors d'un déplacement
  - le dossier `pages/__tests__/` global reste réservé aux tests transverses + tests des pages legacy

### Validation

- Validé le : 29-04-2026
- Contexte technique : Vue 3 / Vite / Vitest — RL799_V2 (5 modules scopés, 35 fichiers extraits)

### Structure type

```
pages/<module>/
├── <Module>Page.vue                # page principale (carcasse + template)
├── <Autre>Page.vue                 # autres pages du même module si existent
├── composables/                    # logique métier extraite
│   ├── use<X>.ts
│   └── use<Y>.ts
├── components/                     # sous-composants .vue scopés au module
├── utils/                          # helpers purs (formatters, defensive wrappers)
├── styles.css                      # CSS partagé non-scoped (cf. pattern dédié)
└── __tests__/                      # tests scopés au module
```

### Ce qui reste dans `pages/__tests__/` global

Trois cas légitimes uniquement :

1. **Tests transverses** qui couvrent plusieurs modules (`lifecycleUnification.test.mjs`)
2. **Tests d'infrastructure** non rattachés à un module métier (`OfflineIntegration.test.mjs` pour le SW PWA)
3. **Tests des pages encore à plat** (legacy non-encore scopées) — `LoginPage.test.mjs` reste à `pages/__tests__/` tant que `LoginPage.vue` est à `pages/`

### Calcul de `root` dans les tests scopés

```typescript
const here = dirname(fileURLToPath(import.meta.url));
// 4 niveaux : __tests__ → <module> → pages → src → frontend
const root = resolve(here, '../../../..');
```

Si le test crashe avec `ENOENT: no such file … '<frontend>/src/src/pages/...'`, c'est que le `root` n'a pas été ajusté.

### Imports : alias `@/` plutôt que relatif

Toujours utiliser l'alias `@/pages/<module>/<X>` plutôt que `./X` ou `../X`. Bénéfice : déplacer un fichier ne casse pas ses imports internes (juste les imports depuis l'extérieur, qu'on met à jour via sed bulk).

### Critère extraction composable vs composant

| Cas | Préférer composable | Préférer composant |
|-----|---------------------|---------------------|
| Logique pure (state + actions, pas de markup) | ✓ | |
| Modale auto-contenue, > 30 lignes template | | ✓ |
| Form > 50 lignes avec validation | | ✓ |
| Plusieurs refs/computeds entrelacés | ✓ | |
| CSS spécifique > 50 lignes | | ✓ (avec styles.css si partagé) |

Préférence générale : composables script-only quand possible (risque CSS nul, plus simple à tester).

---

<a id="pattern-styles-css-module-non-scoped"></a>
## Pattern : `styles.css` partagé non-scoped pour modules avec composants extraits

### Synthèse

- **Objectif** : partager des classes CSS entre la page parente et ses sous-composants extraits sans dupliquer le CSS dans chaque `<style scoped>` enfant.
- **Contexte** : refactor d'une page Vue monolithique en N composants enfants (modales, forms) qui partagent des classes communes.
- **Quand l'utiliser** : ≥ 2 composants enfants partagent des classes (modales, forms, badges du module).
- **Quand l'éviter** : composants enfants strictement indépendants, ou règle CSS utilisée nulle part ailleurs.

### Analyse

- **Avantages** :
  - une seule définition par classe partagée
  - dérive impossible (un changement profite à tous les composants du module)
- **Limites / vigilance** :
  - tentation de tout sortir en non-scoped "au cas où" → refusé, pollue le namespace global

### Validation

- Validé le : 29-04-2026
- Contexte technique : Vue 3 / scoped CSS — RL799_V2 (152 lignes CSS migrées dans `pages/venerable/styles.css`)

### Pattern

```vue
<!-- pages/<module>/<Module>Page.vue -->
<template>
  <!-- … -->
</template>

<!-- Styles partagés du module : importés en non-scoped pour atteindre
     les composants enfants extraits (modales/forms) qui ne peuvent
     pas hériter d'un `<style scoped>` parent -->
<style src="@/pages/<module>/styles.css"></style>

<style scoped>
/* Classes spécifiques au layout de la page parente uniquement */
</style>
```

Les composants enfants utilisent les classes sans rien importer :

```vue
<template>
  <div class="vm-modal">       <!-- vient de styles.css -->
    <h2 class="vm-modal__title">…</h2>
    <button class="primary">…</button>  <!-- vient du CSS global -->
  </div>
</template>
```

### Quoi mettre dans `styles.css`

- Classes utilisées par > 1 composant enfant du module
- Classes utilisées par la page ET par un composant enfant
- Conventions/tokens visuels propres au module

### Quoi NE PAS y mettre

- Classes utilisées uniquement dans la page parente → `<style scoped>` de la page
- Classes utilisées uniquement dans un seul composant enfant → `<style scoped>` du composant
- Classes globales (`primary`, `ghost`, etc.) qui vivent déjà dans `style.css` global

---

<a id="pattern-annuaire-client-side-ttl-refresh"></a>
## Pattern : Annuaire client-side avec TTL + refresh + `lastFetchedAt`

### Synthèse

- **Objectif** : permettre un load complet en mémoire au mount avec filtre client (annuaire de N membres, catalog de produits) tout en évitant le refetch inutile entre ouvertures.
- **Contexte** : composable Vue qui charge un dataset moyen (< quelques milliers d'items) consommé par filtres client.
- **Quand l'utiliser** : modale ou page consultée fréquemment qui n'a pas besoin de pagination serveur.
- **Quand l'éviter** : dataset > 10k items (utiliser pagination/keyset), ou besoin de temps réel cross-clients (basculer SSE/WS).

### Analyse

- **Avantages** :
  - cache TTL configurable (par défaut 5 min) → pas de refetch entre ouvertures
  - `refresh()` méthode publique pour forcer après création/update
  - `lastFetchedAt` exposé pour debug / UI ("annuaire mis à jour il y a X")
- **Limites / vigilance** :
  - si user B crée une entrée pendant que user A a la modale ouverte, A ne voit pas l'entrée tant qu'il ne ferme/rouvre pas ou clique refresh
  - TTL atténue mais ne résout pas — pour temps réel, basculer SSE/WS

### Validation

- Validé le : 01-05-2026
- Contexte technique : Vue 3 / Composition API — RL799_V2

### Implémentation

```typescript
const useEntityDirectory = (options: { ttlMs?: number } = {}) => {
  const ttlMs = options.ttlMs ?? 5 * 60 * 1000;
  const directory = ref<Entry[]>([]);
  const lastFetchedAt = ref<Date | null>(null);

  const isCacheStale = (): boolean => {
    if (!lastFetchedAt.value) return true;
    return Date.now() - lastFetchedAt.value.getTime() > ttlMs;
  };

  const loadDirectory = async (params: { force?: boolean } = {}) => {
    if (!params.force && !isCacheStale() && directory.value.length > 0) return;
    const data = await api.fetchDirectory();
    directory.value = data;
    lastFetchedAt.value = new Date();
  };

  const refresh = () => loadDirectory({ force: true });
  return { directory, lastFetchedAt, loadDirectory, refresh };
};
```

---

<a id="pattern-race-token-partage-latest-wins"></a>
## Pattern : Race-token partagé (latest-wins) dans un store paginé

### Synthèse

- **Objectif** : garantir qu'un store paginé applique toujours le résultat de la dernière intention utilisateur, même si les réponses réseau reviennent dans le désordre.
- **Contexte** : store de domaine (Zustand, Pinia…) où plusieurs actions mutent la même liste (`fetchFeed`, `refresh`, `loadMore`) et peuvent être déclenchées en parallèle (changement de filtre pendant un load, spam de chips).
- **Quand l'utiliser** : dès qu'un changement de filtre/onglet peut survenir pendant un chargement déjà en vol.
- **Quand l'éviter** : action unique sans concurrence possible, ou actions coûteuses où il vaut mieux annuler les requêtes stale (voir vigilance).

### Analyse

- **Avantages** :
  - latest-wins : la dernière intention gagne quel que soit l'ordre de résolution réseau
  - un seul compteur partagé protège toutes les actions de la liste, pas seulement `fetchFeed`
  - propre pour tests + HMR si le compteur vit dans la closure du `create` (par-instance)
- **Limites / vigilance** :
  - un guard `if (get().isLoading) return;` sur `fetchFeed` est un **anti-pattern** : il drop silencieusement un changement de filtre survenu pendant le load initial
  - race-token appliqué **uniquement** à `fetchFeed` est insuffisant : un `loadMore` in-flight peut résoudre après un `fetchFeed` plus récent et concaténer des items de l'ancien filtre
  - N taps → N requêtes réseau (acceptable pour chips/toggles ; pour actions coûteuses, préférer `AbortController` côté http-client)
  - `reset()` doit remettre le compteur à 0 (sinon pollution entre tests / après HMR)

### Validation

- Validé le : 20-05-2026
- Contexte technique : React Native / Expo / Zustand — app-alexandrie (stories 11.2 / 11.3)

### Implémentation

```typescript
export const useFeedStore = create((set, get) => {
  let lastFetchId = 0; // partagé entre toutes les actions, vit dans la closure
  return {
    async fetchFeed(token, opts) {
      const myId = ++lastFetchId;
      set({ isLoading: true });
      try {
        const data = await service.getFeed(token, opts);
        if (myId !== lastFetchId) return; // stale → drop
        set({ items: data.items, isLoading: false });
      } catch (err) {
        if (myId !== lastFetchId) return;
        set({ error: String(err), isLoading: false });
      }
    },
    async refresh(token, opts) {
      if (get().isRefreshing) return;
      const myId = ++lastFetchId; // même compteur
      // … idem, applique seulement si myId === lastFetchId
    },
    async loadMore(token, opts) {
      const { hasMore, nextCursor, isLoadingMore } = get();
      if (!hasMore || !nextCursor || isLoadingMore) return;
      const myId = ++lastFetchId; // même compteur
      // … append uniquement si myId === lastFetchId (sinon stale → drop)
    },
    reset: () => { lastFetchId = 0; set({ ...initialState }); },
  };
});
```

### Documenter le design par un test (pas par un commentaire)

L'absence de guard `isLoading` ressemble à un bug en review. Le test EST la spec : il faut un test qui démontre le latest-wins, sinon la prochaine revue interprétera (à tort) la suppression du guard comme une régression.

```typescript
it('3 fetchFeed concurrents résolus dans le désordre : seul le dernier applique', async () => {
  // lancer p1, p2, p3 avant qu'aucun ne résolve
  // résoudre dans l'ordre p2 → p1 → p3
  // assert : items reflètent uniquement p3
});
```

### Checklist

- [ ] Compteur `lastFetchId` partagé entre `fetchFeed` / `refresh` / `loadMore`
- [ ] Chaque action drop son résultat si `myId !== lastFetchId`
- [ ] `reset()` remet le compteur à 0
- [ ] Test "réponses dans le désordre → dernier gagne"
- [ ] Test "loadMore in-flight pendant fetchFeed → loadMore drop"

---

<a id="pattern-capture-synchrone-before-async-zustand"></a>
## Pattern : Capture synchrone "before" dans une action async (anti race latch)

### Synthèse

- **Objectif** : pour une transition monotone (latch) calculée à partir de l'état précédent, figer l'état observé **avant** le `await` pour éviter qu'un appel concurrent ne corrompe le calcul.
- **Contexte** : action async d'un store qui dérive un nouvel état de l'ancien (`previousIsActive`, latch d'abonnement…) et qui peut être appelée en parallèle.
- **Quand l'utiliser** : transitions monotones / latch lus depuis `get()` puis recombinés après `await`.
- **Quand l'éviter** : action sans dépendance à l'état précédent, ou action non-monotone (préférer alors le race-token latest-wins).

### Analyse

- **Avantages** :
  - le calcul observe un snapshot cohérent, immunisé contre une mutation concurrente
  - complémentaire du race-token (qui résout les actions non-monotones)
- **Limites / vigilance** :
  - lire `get()` **après** le `await` expose à un état déjà muté par un second appel

### Validation

- Validé le : 27-05-2026
- Contexte technique : React Native / Zustand — app-alexandrie (`entitlements.store.ts`, fix H1 IA-v2.5)

### Implémentation

```typescript
// ❌ MAUVAIS — état lu APRÈS await, peut être muté par un appel concurrent
fetchEntitlements: async (token) => {
  set({ isLoading: true });
  const data = await service.getMe(token);
  const wasActive = get().subscription?.isActive ?? false; // lu trop tard
  set({ subscription: data.subscription, previousIsActive: wasActive ? true : null });
}

// ✅ BON — capture synchrone "before" avant l'await
fetchEntitlements: async (token) => {
  const before = get();
  const wasActive = before.subscription?.isActive ?? false;
  const wasLatched = before.previousIsActive === true;
  set({ isLoading: true });
  const data = await service.getMe(token);
  const isNowActive = data.subscription.isActive;
  const previousIsActive = wasLatched || wasActive || isNowActive ? true : null;
  set({ subscription: data.subscription, previousIsActive });
}
```

**Test associé** : `Promise.all([store.action(), store.action()])` doit produire le même état final que deux appels séquentiels.

---

<a id="pattern-event-bus-zustand-timestamp"></a>
## Pattern : Event bus via timestamp pour signaux UI inter-composants

### Synthèse

- **Objectif** : envoyer un signal d'un composant à un autre non-parent (ex. BottomBar → écran de l'onglet actif "rafraîchis-toi") sans prop drilling, sans EventEmitter à nettoyer, sans Context qui re-render tout le sous-arbre.
- **Contexte** : store minimal posant un `timestamp` par cible ; un hook consommateur déclenche un callback au changement de timestamp.
- **Quand l'utiliser** : signal fire-and-forget ponctuel entre composants découplés.
- **Quand l'éviter** : flux de données continu (préférer un state dérivé) ou parent-enfant direct (props/events suffisent).

### Analyse

- **Avantages** :
  - le timestamp seul déclenche le `useEffect` (pas un state data → pas de boucle)
  - le ref vivant sur le callback dispense l'appelant de `useCallback`
  - testable comme un store classique (`setState`, assertions sur la map)
- **Limites / vigilance** :
  - ❌ booléen + reset : race entre consommateurs, reset dur à placer
  - ❌ EventEmitter Node-style : pas de garantie de re-render, cleanup à gérer
  - ❌ Context React : re-render tout le sous-arbre à chaque tap

### Validation

- Validé le : 27-05-2026
- Contexte technique : React Native / Zustand — app-alexandrie (IA-v2.8 AC1)

### Implémentation

```typescript
type RefreshableTab = 'explore' | 'community' | 'messages' | 'library';
export const useTabActionStore = create<{
  refreshTimestamps: Partial<Record<RefreshableTab, number>>;
  requestRefresh: (tab: RefreshableTab) => void;
}>((set) => ({
  refreshTimestamps: {},
  requestRefresh: (tab) =>
    set((s) => ({ refreshTimestamps: { ...s.refreshTimestamps, [tab]: Date.now() } })),
}));

// émetteur — fire & forget
useTabActionStore.getState().requestRefresh('explore');

// consommateur — hook réutilisable
export function useTabRefresh(tab: RefreshableTab, onRefresh: () => void) {
  const timestamp = useTabActionStore((s) => s.refreshTimestamps[tab]);
  const lastSeenRef = useRef<number | undefined>(undefined);
  const onRefreshRef = useRef(onRefresh);
  onRefreshRef.current = onRefresh; // ref vivant, pas de boucle deps
  useEffect(() => {
    if (timestamp === undefined || timestamp === lastSeenRef.current) return;
    lastSeenRef.current = timestamp;
    onRefreshRef.current();
  }, [timestamp]);
}
```

---

<a id="pattern-cache-zustand-cle-composite"></a>
## Pattern : Clé de cache composite sur action async paramétrée

### Synthèse

- **Objectif** : éviter de servir un cache stale quand une action async indexe son résultat sur un seul paramètre alors que d'autres paramètres modifient le résultat.
- **Contexte** : store qui mémorise un résultat par paramètre métier (`slug`, `id`) et qui court-circuite le refetch via une égalité de clé.
- **Quand l'utiliser** : dès qu'une action de fetch prend plus d'un paramètre influençant le résultat.
- **Quand l'éviter** : action mono-paramètre, ou cache géré par une lib (React Query) qui clé déjà sur l'ensemble des args.

### Analyse

- **Avantages** :
  - le cache reflète exactement les paramètres qui produisent le résultat
  - pas de dépendance fragile à un `clearXxx()` externe
- **Limites / vigilance** :
  - clé partielle (`packSlug` seul) → navigation A→B du même pack sert B avec l'exclusion de A toujours active

### Validation

- Validé le : 29-05-2026
- Contexte technique : Zustand — app-alexandrie (code review ux-cleanup-7)

### Implémentation

```typescript
// ❌ Cache stale si excludeId change mais pas packSlug
async fetchPackContents(token, packSlug, excludeId) {
  if (get().packContentsSlug === packSlug) return;
  // …
}

// ✅ Clé composite : un champ d'état par paramètre métier
async fetchPackContents(token, packSlug, excludeId) {
  const normalizedExcludeId = excludeId ?? null;
  const sameKey =
    get().packContentsSlug === packSlug &&
    get().packContentsExcludeId === normalizedExcludeId;
  if (sameKey && (get().isLoading || get().items.length > 0)) return;
  // …
  set({ packContentsSlug: packSlug, packContentsExcludeId: normalizedExcludeId });
}
```

**Règle** : N paramètres métier influençant le résultat → N champs de clé dans l'état.

---

<a id="pattern-loadings-separes-initial-pagination"></a>
## Pattern : Loadings séparés (fetch initial vs pagination)

### Synthèse

- **Objectif** : éviter que le pull-to-refresh tourne pendant l'infinite scroll en distinguant deux flags de chargement de natures différentes.
- **Contexte** : store qui supporte à la fois fetch initial/refresh ET pagination (`fetchNextPage`).
- **Quand l'utiliser** : toute liste avec `RefreshControl` + chargement de pages.
- **Quand l'éviter** : liste sans pagination, ou sans pull-to-refresh.

### Analyse

- **Avantages** : le `RefreshControl` ne s'anime que pour le refresh ; le spinner de bas de liste pour la pagination.
- **Limites / vigilance** : un seul `isLoading` partagé → l'écran clignote, le refresh tourne pendant le scroll. Côté anti-pattern, voir aussi `risques/state.md#risque-flag-isloading-unique-nature-differente`.

### Validation

- Validé le : 29-05-2026
- Contexte technique : React Native / Zustand — app-alexandrie (ux-cleanup-10 M4, `notifications.store.ts`)

### Implémentation

```typescript
type State = {
  items: T[];
  isLoading: boolean;     // fetch initial OU pull-to-refresh
  isLoadingMore: boolean; // pagination (fetchNextPage)
};
```

```tsx
<FlatList
  refreshing={isLoading}                          // pull-to-refresh seulement
  ListFooterComponent={isLoadingMore ? <Spinner/> : null}
  onEndReached={() => store.fetchNextPage(...)}
/>
```

---

<a id="pattern-flags-etat-separes-par-preoccupation"></a>
## Pattern : Flags d'état séparés par préoccupation (liste / création / mutation-par-item)

### Synthèse

- **Objectif** : éviter qu'un `isSubmitting`/`error` unique partagé entre `create`, `update(id)` et `remove(id)` ne fasse passer tous les boutons d'une liste en `loading` quand on agit sur une seule ligne.
- **Contexte** : store qui gère liste + création + mutation-par-item (agnostique Pinia/Vuex/Redux/Zustand).
- **Quand l'utiliser** : dès qu'une liste a des actions par item ET un formulaire de création.
- **Quand l'éviter** : store mono-action sans liste interactive.

### Analyse

- **Avantages** : chaque préoccupation a son flag → boutons ciblés, actions enchaînables, erreurs au bon endroit.
- **Limites / vigilance** : un `isSubmitting` unique provoque (1) tous les boutons de toutes les lignes en `loading`, (2) actions non-enchaînables, (3) erreur de mutation affichée dans un formulaire de création sans rapport. Pour le variant booléen-vs-`Set`, voir `risques/state.md#risque-flag-global-actions-paralleles`.

### Validation

- Validé le : 13-06-2026
- Contexte technique : Vue 3 / Pinia — RL799_V2 (`instructionsStore.ts`, code review)

### Implémentation

```typescript
// ❌ partagé : :loading="store.isSubmitting" sur chaque ligne
// ✅ séparé par préoccupation
isLoading / loadError          // chargement de la liste
isCreating / createError       // formulaire de création
submittingId: string | null    // mutation par item
mutationError                  // erreur de mutation
// UI ligne : :loading="store.submittingId === item.id"
```

---

<a id="pattern-derive-computed-pas-ref-resync"></a>
## Pattern : État dérivé = `computed`, jamais un `ref` resynchronisé à la main

### Synthèse

- **Objectif** : supprimer les désyncs silencieuses d'un compteur/dérivé recalculé manuellement à chaque chemin de mutation.
- **Contexte** : composable Vue 3 exposant une valeur toujours dérivée d'un autre état réactif (`size`, `length`, total, booléen `isEmpty`).
- **Quand l'utiliser** : toute valeur jamais assignée indépendamment, toujours recalculée depuis une source.
- **Quand l'éviter** : valeur réellement indépendante (saisie utilisateur, état piloté en propre).

### Analyse

- **Avantages** : recalcul automatique à chaque changement de la source, zéro synchro manuelle, impossible d'oublier un chemin.
- **Limites / vigilance** :
  - anti-pattern : `const count = ref(set.value.size)` + `syncCount()` rappelé dans chaque mutation → un oubli produit un compteur faux **sans erreur**
  - exposer le dérivé en `ComputedRef<T>` (pas `Ref<T>`) pour signaler le read-only
  - réactivité `Set`/`Map` : la mutation in-place (`set.add()`) ne déclenche rien — réassigner (`checked.value = new Set(checked.value)`) ; un `computed(() => checked.value.size)` se met alors à jour (il dépend de l'identité du Set)
- **Garde-fou de revue** : dans un composable, tout `ref` toujours recalculé depuis un autre `ref` est un `computed` déguisé.

### Validation

- Validé le : 18-06-2026
- Contexte technique : Vue 3 / Composition API — RL799_V2 (`useMcChecklist`, code review v2-2-2)

---

<a id="pattern-une-source-deux-vues-lecture-inerte"></a>
## Pattern : Une source, deux vues — la vue lecture inerte par construction

### Synthèse

- **Objectif** : présenter la même donnée en mode édition ET en mode lecture/présentation sans dupliquer la donnée ni la logique, en prouvant l'inertie du mode lecture **par construction** (aucun mutateur câblé) plutôt que par un garde runtime.
- **Contexte** : écran consultation/édition partageant un même fetch (préparation vs pédagogique, édition vs consultation).
- **Quand l'utiliser** : deux rendus d'une même donnée pilotés par un `viewMode` local.
- **Quand l'éviter** : modes aux données réellement disjointes.

### Analyse

- **Avantages** :
  - un seul fetch, deux rendus via `v-if/v-else` sur un `ref<'edit'|'read'>`
  - le mode lecture ne **référence** aucun mutateur → inertie garantie, pas besoin de `if (mode === 'read') return` dans chaque handler
  - un composable d'état qui **lit** au mount reste inerte tant que ses mutateurs ne sont pas appelés (la lecture n'est pas un effet de bord) → il peut être instancié pour les deux modes
- **Limites / vigilance** :
  - `viewMode` est un état d'affichage **local non persisté** (pas de la donnée métier)
  - préférer un toggle in-place (un `viewMode` + `v-if` dans la page) à l'extraction d'un composant partagé tant qu'une autre story touche le même rendu — surface de merge minimale, factoriser à la 3ᵉ vraie divergence
- **Prouver l'inertie par test** : monter en mode lecture, spy sur `localStorage.setItem`/`fetch`, cliquer les éléments lecture, asserter aucun appel + clé localStorage restée nulle.

### Validation

- Validé le : 18-06-2026
- Contexte technique : Vue 3 — RL799_V2 (vue pédagogique MC, segmented control préparation↔pédagogique, code review v2-2-3)

---

<a id="pattern-noyau-visuel-partage-variants"></a>
## Pattern : Noyau visuel générique + comportements variants greffés

### Synthèse

- **Objectif** : faire servir un même artefact visuel positionnel (plan, diagramme, grille de placement) à deux finalités différentes sans dupliquer le rendu ni coupler les intentions via un `variant`.
- **Contexte** : un visuel (SVG, carte, plateau) doit afficher un état ET servir de sélecteur de navigation.
- **Quand l'utiliser** : à la **2ᵉ** utilisation de la même géométrie (pas avant — règle « factoriser à la Nᵉ utilisation »).
- **Quand l'éviter** : usage unique, ou intentions trop divergentes pour partager un contrat de props minimal.

### Analyse

- **Avantages** :
  - le noyau ne connaît QUE l'invariant : géométrie, accessibilité clavier, contrat `nodes: {id, state}`, un seul événement neutre `select(id)`
  - les consommateurs sont des **adaptateurs minces** : ils mappent leurs données métier vers `nodes` et retraduisent `select(id)`
  - le 1er consommateur garde une signature publique inchangée (zéro régression, prouvée par typecheck + test de mount), le 2ᵉ usage est purement additif
- **Limites / vigilance** :
  - mettre l'état « inerte » (plateau non cliquable) dans le **noyau** (garde de non-émission au clic ET au clavier), pas seulement en CSS ni dans chaque adaptateur
  - dériver `nodes` d'une source unique exhaustive (ex. `RITUAL_OFFICER_ROLES`) plutôt qu'une liste en dur → un ajout futur apparaît automatiquement, pas de nœud manquant silencieux

### Validation

- Validé le : 13-06-2026
- Contexte technique : Vue 3 / SVG — RL799_V2 (`LodgeFloorPlanBase` consommé par `LodgeFloorPlan` collège + `ModuleFloorPlan` navigation, chantier surveillant-mobile-floorplan)
