Files
_Assistant_Lead_Tech/knowledge/frontend/patterns/state.md
MaksTinyWorkshop b3417ad77b capitalisation: intégration ~60 entrées RL799_V2 (triage 2026-05-02)
Triage du 95_a_capitaliser.md (~75 propositions) :
- 60 entrées intégrées dans knowledge/ (backend, frontend, workflow)
- 4 nouveaux fichiers : backend/patterns/tests.md, backend/risques/tests.md,
  frontend/patterns/general.md, workflow/patterns/general.md
- 6 doublons rejetés
- Mise à jour des READMEs index pour refléter les nouvelles entrées
- 95_a_capitaliser.md restauré à sa structure initiale
- 40_decisions_et_archi.md : décision mono-tenant déployable vs SaaS multi-tenant
- 90_debug_et_postmortem.md : sub-agents Write indisponible, effet iceberg CI,
  prisma migrate diffs cosmétiques

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 22:12:44 +02:00

19 KiB

Frontend — Patterns : State

Extrait de la base de connaissance Lead_tech. Voir knowledge/frontend/patterns/README.md pour 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 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)

- 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

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-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) :

// 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)

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

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à dans style.css global

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

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 };
};