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>
38 KiB
Frontend — Patterns : State
Extrait de la base de connaissance Lead_tech. Voir
knowledge/frontend/patterns/README.mdpour l'index complet.
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)
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
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)
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
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
loadMoreetrefresh. - 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
refreshetloadMore - demande une clé d'identité stable pour dédupliquer les items
- impose une discipline claire entre
Validation
- Validé le : 10-03-2026
- Contexte technique : React Native / Expo / Zustand / listes paginées
Implémentation (exemple minimal)
- 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
refreshetloadMoreont 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
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)
- 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
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.
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-testidE2E 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 --noEmitne suffit pas — utiliservue-tsc(cf.frontend/risques/state.mdrisque-templates-vue-references-orphelines)
- les
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
- Audit complet préalable : sections du template avec plages de lignes + rôle métier +
v-ifclé, blocs script regroupés par responsabilité avec évaluationautonomevscouplé, imports + composables + DTOs consommés, tests existants, couplages externes (deep-links, sélecteurs CSS). - 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)
- 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) :
// 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-testidcopié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(pastsc) en vérification typecheck- QA visuel obligatoire post-refactor (mount réel en browser)
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
rootavec 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
- les tests scopés calculent leur
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 :
- Tests transverses qui couvrent plusieurs modules (
lifecycleUnification.test.mjs) - Tests d'infrastructure non rattachés à un module métier (
OfflineIntegration.test.mjspour le SW PWA) - Tests des pages encore à plat (legacy non-encore scopées) —
LoginPage.test.mjsreste àpages/__tests__/tant queLoginPage.vueest àpages/
Calcul de root dans les tests scopés
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).
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
<!-- 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 :
<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à dansstyle.cssglobal
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/updatelastFetchedAtexposé 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
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 };
};
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;surfetchFeedest un anti-pattern : il drop silencieusement un changement de filtre survenu pendant le load initial - race-token appliqué uniquement à
fetchFeedest insuffisant : unloadMorein-flight peut résoudre après unfetchFeedplus 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
AbortControllercôté http-client) reset()doit remettre le compteur à 0 (sinon pollution entre tests / après HMR)
- un guard
Validation
- Validé le : 20-05-2026
- Contexte technique : React Native / Expo / Zustand — app-alexandrie (stories 11.2 / 11.3)
Implémentation
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.
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
lastFetchIdpartagé entrefetchFeed/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"
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
awaitpour é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èsawait. - 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 leawaitexpose à un état déjà muté par un second appel
- lire
Validation
- Validé le : 27-05-2026
- Contexte technique : React Native / Zustand — app-alexandrie (
entitlements.store.ts, fix H1 IA-v2.5)
Implémentation
// ❌ 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.
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
timestamppar 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)
- le timestamp seul déclenche le
- 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
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]);
}
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 (
packSlugseul) → navigation A→B du même pack sert B avec l'exclusion de A toujours active
- clé partielle (
Validation
- Validé le : 29-05-2026
- Contexte technique : Zustand — app-alexandrie (code review ux-cleanup-7)
Implémentation
// ❌ 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.
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
RefreshControlne s'anime que pour le refresh ; le spinner de bas de liste pour la pagination. - Limites / vigilance : un seul
isLoadingpartagé → l'écran clignote, le refresh tourne pendant le scroll. Côté anti-pattern, voir aussirisques/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
type State = {
items: T[];
isLoading: boolean; // fetch initial OU pull-to-refresh
isLoadingMore: boolean; // pagination (fetchNextPage)
};
<FlatList
refreshing={isLoading} // pull-to-refresh seulement
ListFooterComponent={isLoadingMore ? <Spinner/> : null}
onEndReached={() => store.fetchNextPage(...)}
/>
Pattern : Flags d'état séparés par préoccupation (liste / création / mutation-par-item)
Synthèse
- Objectif : éviter qu'un
isSubmitting/errorunique partagé entrecreate,update(id)etremove(id)ne fasse passer tous les boutons d'une liste enloadingquand 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
isSubmittingunique provoque (1) tous les boutons de toutes les lignes enloading, (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, voirrisques/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
// ❌ 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"
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éenisEmpty). - 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>(pasRef<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)) ; uncomputed(() => checked.value.size)se met alors à jour (il dépend de l'identité du Set)
- anti-pattern :
- Garde-fou de revue : dans un composable, tout
reftoujours recalculé depuis un autrerefest uncomputeddéguisé.
Validation
- Validé le : 18-06-2026
- Contexte technique : Vue 3 / Composition API — RL799_V2 (
useMcChecklist, code review v2-2-2)
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
viewModelocal. - Quand l'éviter : modes aux données réellement disjointes.
Analyse
- Avantages :
- un seul fetch, deux rendus via
v-if/v-elsesur unref<'edit'|'read'> - le mode lecture ne référence aucun mutateur → inertie garantie, pas besoin de
if (mode === 'read') returndans 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
- un seul fetch, deux rendus via
- Limites / vigilance :
viewModeest un état d'affichage local non persisté (pas de la donnée métier)- préférer un toggle in-place (un
viewMode+v-ifdans 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)
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 neutreselect(id) - les consommateurs sont des adaptateurs minces : ils mappent leurs données métier vers
nodeset retraduisentselect(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
- le noyau ne connaît QUE l'invariant : géométrie, accessibilité clavier, contrat
- 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
nodesd'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 (
LodgeFloorPlanBaseconsommé parLodgeFloorPlancollège +ModuleFloorPlannavigation, chantier surveillant-mobile-floorplan)