Étude de cas : Liste et détail utilisateur
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:QoraClientinjecté à la racine avecFlutterLifecycleManageretFlutterConnectivityManager 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é
FetchStatuscomme 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 :
RefreshIndicatorcâ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 :
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,FailureFetchStatus: 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 :
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 :
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 :
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 :
- Ouvrir Liste d'utilisateurs → attendre 2 s pour le chargement initial
- Revenir en arrière et rouvrir → instantané depuis le cache, sans appel réseau
- Pull-to-refresh → la liste périmée reste visible ; la bannière bleue "Mise à jour..." apparaît simultanément
- Ouvrir un utilisateur → noter l'horodatage "Dernier chargement"
- Revenir et rouvrir dans les 5 min → instantané depuis le cache, horodatage inchangé
- 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 aussi
- Démarrage rapide: présentation conceptuelle pas à pas de la même fonctionnalité
- QoraBuilder: API widget complète
- Stale-While-Revalidate: fonctionnement du modèle de fraîcheur du cache
- États des requêtes: référence de la classe sealed
QoraState
Avec GraphQL
Envelopper les clients GraphQL graphql ou ferry dans des fetchers Qora pour une gestion fluide des requêtes avec stale-while-revalidate, invalidation de cache et pagination.
Étude de cas : Mutations optimistes et rollback
Une implémentation complète de mise à jour optimiste : setQueryData pour un retour UI immédiat, restoreQueryData pour le rollback automatique en cas d'échec, et invalidateWhere pour la réconciliation serveur.