Exemples

Étude de cas : Liste et détail utilisateur

Une fonctionnalité liste-détail complète : cache SWR, état UI à deux axes avec FetchStatus, clés de requête par élément, pull-to-refresh et dégradation gracieuse. Zéro boilerplate de gestion d'état.

Objectif

Contexte : Chaque écran alimenté par le réseau doit gérer les cinq mêmes préoccupations : état de chargement, état d'erreur, données en cache, rafraîchissement en arrière-plan et connectivité. Avec un FutureBuilder brut ou un setState manuel, chaque écran réimplémente cette logique indépendamment. Le code est verbeux, incohérent et fragile sous navigation.

Cet exemple montre comment QoraBuilder consolide ces préoccupations en un seul builder déclaratif. Le cache SWR de Qora permet aux utilisateurs de rouvrir un écran visité sans requête réseau supplémentaire.


Fonctionnalités démontrées

  • Configuration QoraScope : QoraClient injecté à la racine avec FlutterLifecycleManager et FlutterConnectivityManager
  • QoraBuilder<T> : fetche au montage, s'abonne aux changements d'état, dispose proprement
  • Stale-While-Revalidate : données en cache affichées instantanément à la re-navigation ; refetch en arrière-plan uniquement quand périmé
  • FetchStatus comme second axe UI : distingue le fetch actif (fetching), la pause hors ligne (paused) et l'état inactif sans aucun flag booléen personnalisé
  • Dégradation gracieuse via previousData : les données périmées restent visibles pendant le rafraîchissement en arrière-plan et lors d'erreurs légères
  • Clés de requête par élément : ['users', userId] donne à chaque utilisateur une entrée de cache séparée et indépendante
  • Prédicat invalidateWhere : un seul appel invalide simultanément la liste et tous les écrans de détail ouverts
  • Pull-to-refresh : RefreshIndicator câblé à invalidateWhere, aucun état manuel requis

Points d'architecture

Les widgets ne possèdent jamais de données : ils déclarent ce qu'ils veulent et Qora décide de fetcher, servir depuis le cache ou attendre la connectivité :

ex_01_basic_query/lib/
├── main.dart                    ← QoraClient + QoraScope à la racine
├── models/
│   └── user.dart                ← classe de données pure, sans dépendance Qora
├── services/
│   └── fake_api.dart            ← fonctions async (latence simulée 2 s + ~30 % mode échec)
└── screens/
    ├── user_list_screen.dart    ← QoraBuilder<List<User>>, key: ['users']
    └── user_detail_screen.dart  ← QoraBuilder<User>, key: ['users', userId]

QoraClient est configuré une seule fois à la racine. Les écrans y accèdent via context.qora : le raccourci BuildContextExtension vers QoraScope.of(context). Pas de Provider, pas de ref, pas de context.read :

lib/main.dart
final client = QoraClient(
  config: const QoraClientConfig(
    defaultOptions: QoraOptions(
      staleTime: Duration(minutes: 5),
      gcTime: Duration(minutes: 10),
    ),
    debugMode: kDebugMode,
  ),
);

runApp(
  QoraScope(
    client: client,
    lifecycleManager: FlutterLifecycleManager(qoraClient: client),
    connectivityManager: FlutterConnectivityManager(),
    child: const MyApp(),
  ),
);

staleTime: 5 minutes signifie : si vous naviguez vers un écran visité il y a moins de 5 minutes, aucun appel réseau ne se déclenche : le cache sert les données à la première frame. Après 5 minutes, les données en cache sont affichées immédiatement pendant que Qora revalide en arrière-plan.


Détails d'implémentation

La matrice d'état à deux dimensions

La plupart des solutions de gestion d'état donnent un axe : loading / data / error. Qora en donne deux :

  • QoraState<T> : ce que vous avez : Initial, Loading, Success, Failure
  • FetchStatus : ce que Qora fait en ce moment : fetching, paused, idle

Ces deux axes sont indépendants. Un widget peut être dans l'état Success (données visibles) pendant que FetchStatus est fetching (revalidation SWR en arrière-plan en cours) : l'utilisateur voit ses données et une bannière "Mise à jour..." discrète, mais sans spinner ni écran vide. Le UserListScreen affiche toutes les combinaisons pertinentes sans un seul bool _isLoading ou bool _isOffline :

lib/screens/user_list_screen.dart
QoraBuilder<List<User>>(
  queryKey: const ['users'],
  fetcher: FakeApi.getUsers,
  options: const QoraOptions(
    staleTime: Duration(minutes: 5),
    gcTime: Duration(minutes: 10),
  ),
  builder: (context, state, fetchStatus) {

    // ── Axe 2: FetchStatus → bannière de statut en haut ───────────────
    final banner = switch (fetchStatus) {
      FetchStatus.fetching => const _StatusBanner(
          icon: Icons.sync,
          label: 'Mise à jour…',
          color: Colors.blue,
        ),
      FetchStatus.paused => const _StatusBanner(
          icon: Icons.wifi_off,
          label: 'Hors ligne : données en cache affichées',
          color: Colors.orange,
        ),
      FetchStatus.idle => const SizedBox.shrink(),
    };

    // ── Axe 1: QoraState → contenu principal ──────────────────────────
    return switch (state) {

      // Premier chargement sans cache, ou hors ligne sans cache du tout
      Initial() || Loading(previousData: null) =>
          const Center(child: CircularProgressIndicator()),

      // Erreur critique : rien à afficher
      Failure(:final error, previousData: null) => _ErrorScreen(
          error: error,
          onRetry: () => context.qora.invalidateWhere(
            (key) => key.firstOrNull == 'users',
          ),
        ),

      // Données disponibles dans TOUS les cas restants :
      //   Success        → données fraîches
      //   Loading(data)  → données périmées, SWR en cours
      //   Failure(data)  → rafraîchissement échoué, données périmées toujours affichées
      _ => Column(
          children: [
            banner,
            if (state is Failure<List<User>>)
              _ErrorBanner(error: state.error), // erreur légère : liste toujours visible
            Expanded(
              child: RefreshIndicator(
                onRefresh: () async {
                  context.qora.invalidateWhere(
                    (key) => key.firstOrNull == 'users',
                  );
                  await Future<void>.delayed(const Duration(milliseconds: 300));
                },
                child: ListView.builder(
                  itemCount: state.dataOrNull?.length ?? 0,
                  itemBuilder: (_, i) => _UserTile(user: state.dataOrNull![i]),
                ),
              ),
            ),
          ],
        ),
    };
  },
)

Le pattern Loading(previousData: null) est une déstructuration Dart 3 sur la classe sealed : il correspond à Loading uniquement quand previousData est null, c'est-à-dire le tout premier chargement sans cache. Tout Loading ultérieur (revalidation SWR en arrière-plan) porte previousData et tombe dans le bras _, gardant la liste visible. C'est pourquoi les utilisateurs ne voient jamais un écran vide ou un spinner plein écran lors de la re-navigation.

invalidateWhere : un appel, toutes les clés liées

Après un pull-to-refresh, ['users'] (la liste) et chaque ['users', userId] (écrans de détail ouverts) ont besoin de données fraîches. Un prédicat les couvre tous :

lib/screens/user_list_screen.dart
context.qora.invalidateWhere((key) => key.firstOrNull == 'users');
// Invalide : ['users'], ['users', '1'], ['users', '2'], …

Chaque QoraBuilder actif abonné à une clé correspondante transite vers Loading(previousData: data) et refetche. Les données existantes restent visibles : aucun écran vide nulle part dans la pile de navigation.

Cache par élément et updatedAt sur l'écran de détail

Chaque UserDetailScreen utilise une clé paramétrée : ['users', userId]. Cela donne à chaque utilisateur sa propre entrée de cache indépendante. L'écran lit également state.updatedAt (le DateTime du dernier fetch réussi) pour afficher un horodatage "dernier chargement il y a X minutes" en direct, rendant le comportement SWR visible à l'utilisateur :

lib/screens/user_detail_screen.dart
QoraBuilder<User>(
  queryKey: ['users', userId],       // ex. ['users', '1']
  fetcher: () => FakeApi.getUser(userId),
  options: const QoraOptions(staleTime: Duration(minutes: 5)),
  builder: (context, state, fetchStatus) {
    return switch (state) {
      Initial() || Loading(previousData: null) =>
          const Center(child: CircularProgressIndicator()),
      Failure(:final error, previousData: null) => _ErrorScreen(error),
      _ => Column(
          children: [
            if (fetchStatus == FetchStatus.paused)
              const _Banner(label: 'Hors ligne : données en cache affichées'),
            if (state is Failure<User>)
              _Banner(label: 'Rafraîchissement échoué : ${state.error}'),
            if (fetchStatus == FetchStatus.fetching)
              const LinearProgressIndicator(minHeight: 2),
            Expanded(
              child: _UserDetailView(
                user: state.dataOrNull!,
                updatedAt: state.updatedAt, // ← horodatage depuis l'état Success
                isStale: state is! Success<User>,
              ),
            ),
          ],
        ),
    };
  },
)

Naviguez vers un utilisateur, revenez, rouvrez : les données apparaissent à la première frame, updatedAt affiche l'heure du fetch d'origine, et un LinearProgressIndicator de 2 px se déclenche uniquement si les données étaient périmées. L'appel réseau se produit silencieusement en arrière-plan.


Étapes de validation

L'exemple inclut un guide interactif pour rendre le comportement du cache observable :

  1. Ouvrir Liste d'utilisateurs → attendre 2 s pour le chargement initial
  2. Revenir en arrière et rouvrir → instantané depuis le cache, sans appel réseau
  3. Pull-to-refresh → la liste périmée reste visible ; la bannière bleue "Mise à jour..." apparaît simultanément
  4. Ouvrir un utilisateur → noter l'horodatage "Dernier chargement"
  5. Revenir et rouvrir dans les 5 min → instantané depuis le cache, horodatage inchangé
  6. Activer le mode avion → bannière orange "Hors ligne" ; la liste reste entièrement utilisable

Code source

examples/ex_01_basic_query/
├── lib/
│   ├── main.dart                    ← configuration QoraClient + QoraScope
│   ├── models/user.dart             ← classe de données pure
│   ├── services/fake_api.dart       ← API simulée (délai 2 s, ~30 % mode échec)
│   └── screens/
│       ├── user_list_screen.dart    ← QoraBuilder + matrice FetchStatus × QoraState
│       └── user_detail_screen.dart  ← clé par élément, updatedAt, previousData
└── pubspec.yaml

Voir le code source complet


Voir aussi

Copyright © 2026