Étude de cas : Mutations optimistes et rollback
Objectif
Contexte : Les opérations d'écriture introduisent un problème de latence. Un aller-retour vers le serveur prend 1 à 3 secondes. Bloquer l'UI jusqu'à la réponse du serveur produit des interactions lentes et brise la réactivité perçue de l'application.
Les mises à jour optimistes résolvent ce problème en appliquant le résultat attendu au cache local immédiatement, avant la réponse du serveur. Si le serveur confirme l'opération, les données optimistes sont remplacées par la réponse autoritaire du serveur. Si le serveur la rejette, le cache est restauré à son état pré-mutation.
Cet exemple implémente le protocole complet de mise à jour optimiste en utilisant QoraMutationBuilder, setQueryData et restoreQueryData. L'API simulée échoue ~30 % du temps pour rendre le rollback observable.
Fonctionnalités démontrées
QoraMutationBuilder<TData, TVariables, TContext>: gère le cycle de vie duMutationController(création au montage, dispose au démontage), câble la file hors ligne automatiquementMutationOptions.onMutate: capture un snapshot de cache pré-mutation (TContext) et appliquesetQueryDataavant l'appel serveurMutationOptions.onError: appellerestoreQueryDataavec le snapshotTContextpour annuler simultanément les deux entrées de cache affectéesMutationOptions.onSuccess: appelleinvalidateWherepour déclencher un refetch en arrière-plan et réconcilier le cache avec l'état confirmé par le serveur- Mise à jour optimiste cross-cache : une seule mutation met à jour deux entrées de cache indépendantes (
['users', id]et['users']) atomiquement viaTContext - Pattern matching sur
MutationState: quatre états (MutationIdle,MutationPending,MutationSuccess,MutationFailure) traités exhaustivement avec des expressions switch Dart 3 - Composition
QoraBuilder+QoraMutationBuilder: opérations de lecture et d'écriture coexistent sur le même écran sans état mutable partagé
Points d'architecture
Le cycle de vie de la mutation est entièrement géré par QoraMutationBuilder. L'arbre de widgets déclare l'intention ; Qora coordonne le cache, le réseau et les transitions d'état :
ex_02_mutations_optimistic/lib/
├── main.dart ← QoraClient + QoraScope à la racine
├── models/
│ └── user.dart ← classe de données pure avec copyWith
├── services/
│ └── fake_api.dart ← API async (latence 2 s, ~30 % d'échec sur updateUser)
└── screens/
├── user_list_screen.dart ← QoraBuilder<List<User>>, key: ['users']
└── rename_user_sheet.dart ← QoraMutationBuilder + protocole optimiste en 3 étapes
QoraClient est configuré une seule fois à la racine. UserListScreen lit la liste d'utilisateurs via QoraBuilder. L'action d'édition capture context.qora et le passe explicitement à RenameUserSheet, car les routes modales s'exécutent dans un overlay de navigator séparé :
Future<void> _showRenameSheet(BuildContext context, User user) {
// Capturer le client avant l'ouverture de la sheet : les routes modales s'exécutent dans
// un overlay de navigator séparé et peuvent ne pas résoudre QoraScope par elles-mêmes.
final client = context.qora;
return showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
useSafeArea: true,
builder: (_) => RenameUserSheet(user: user, client: client),
);
}
Détails d'implémentation
Le protocole de mise à jour optimiste en 3 étapes
Chaque mutation optimiste suit trois hooks séquentiels définis dans MutationOptions. Les paramètres de type encodent les types du domaine avec précision :
TData = User: valeur retournée par le serveur en cas de succèsTVariables = ({String id, String name}): entrée du mutateurTContext = ({User? user, List<User>? list}): snapshot pré-mutation pour le rollback
QoraMutationBuilder<User, _UpdateVars, _Snapshot>(
mutator: (vars) => FakeApi.updateUser(vars.id, name: vars.name),
options: MutationOptions<User, _UpdateVars, _Snapshot>(
onMutate: ..., // Étape 1 : snapshot + setQueryData
onError: ..., // Étape 2 : restoreQueryData (rollback)
onSuccess: ..., // Étape 3 : invalidateWhere (réconciliation)
),
builder: (context, state, mutate) { ... },
)
Les hooks s'exécutent en séquence : onMutate s'exécute avant le mutateur ; onError ou onSuccess s'exécutent après sa résolution. La valeur TContext retournée par onMutate est transmise à onError et onSuccess en tant que troisième paramètre.
Snapshot atomique cross-cache
onMutate capture deux entrées de cache indépendantes avant d'en modifier l'une ou l'autre. Le type record Dart 3 ({User? user, List<User>? list}) porte les deux snapshots comme une seule valeur typée :
onMutate: (vars) async {
// Capturer les données actuelles des deux entrées de cache avant mutation.
final prevUser = client.getQueryData<User>(['users', vars.id]);
final prevList = client.getQueryData<List<User>>(['users']);
// Appliquer le changement de nom optimiste au cache de détail par utilisateur.
if (prevUser != null) {
client.setQueryData<User>(
['users', vars.id],
prevUser.copyWith(name: vars.name),
);
}
// Appliquer le même changement à l'entrée correspondante dans le cache de liste.
if (prevList != null) {
client.setQueryData<List<User>>(
['users'],
prevList
.map((u) => u.id == vars.id ? u.copyWith(name: vars.name) : u)
.toList(),
);
}
// Retourner les deux snapshots comme contexte de rollback (TContext).
return (user: prevUser, list: prevList);
},
setQueryData pousse la nouvelle valeur vers tous les streams QoraBuilder actifs abonnés à la clé correspondante. UserListScreen et tout widget de détail ouvert reçoivent la mise à jour à la frame suivante, avant la complétion de l'appel réseau.
Rollback automatique en cas d'échec
onError reçoit le snapshot TContext retourné par onMutate. restoreQueryData est un no-op quand le snapshot est null, la null-safety est donc préservée sans branchement défensif :
onError: (error, vars, ctx) async {
// Restaurer les deux entrées de cache depuis le snapshot retourné par onMutate.
client.restoreQueryData<User>(['users', vars.id], ctx?.user);
client.restoreQueryData<List<User>>(['users'], ctx?.list);
},
Quand le serveur rejette la mutation, les deux entrées de cache reviennent à leurs valeurs pré-mutation en une seule opération synchrone. Tous les widgets abonnés rebuilden immédiatement, affichant les données originales sans aucune intervention utilisateur.
Réconciliation serveur en cas de succès
onSuccess utilise invalidateWhere pour marquer toutes les entrées de cache liées aux utilisateurs comme périmées. Qora déclenche des refetchs en arrière-plan pour chaque abonné actif, remplaçant les données optimistes par l'état confirmé par le serveur :
onSuccess: (user, vars, _) async {
// Déclencher un refetch en arrière-plan pour toutes les entrées liées aux utilisateurs
// afin que le cache reflète l'état confirmé par le serveur.
client.invalidateWhere((key) => key.firstOrNull == 'users');
},
Le prédicat correspond à la fois à ['users'] et ['users', id] simultanément. Les abonnés actifs transitent vers Loading(previousData: data) : les données optimistes restent visibles pendant le fetch en arrière-plan, et la réponse finale du serveur les remplace sans transition à écran vide.
Pattern matching sur MutationState
QoraMutationBuilder rebuilde à chaque fois que le MutationController émet un nouvel état. Le builder mappe les quatre états vers l'UI :
builder: (context, state, mutate) {
return Column(
children: [
TextField(
controller: _nameController,
enabled: !state.isPending, // désactiver la saisie pendant l'appel serveur
...
),
// Retour de statut inline via l'expression switch Dart 3
switch (state) {
MutationFailure(:final error) => _FeedbackBanner(
icon: Icons.undo_rounded,
message: 'Sauvegarde échouée, nom restauré. $error',
color: Colors.red,
),
MutationSuccess() => const _FeedbackBanner(
icon: Icons.check_circle_outline,
message: 'Sauvegardé. Cache mis à jour depuis le serveur.',
color: Colors.green,
),
_ => const SizedBox.shrink(), // MutationIdle, MutationPending
},
FilledButton(
onPressed: state.isPending ? null : () => mutate((id: user.id, name: name)),
child: state.isPending
? const CircularProgressIndicator(strokeWidth: 2.5)
: const Text('Sauvegarder'),
),
],
);
},
MutationIdle et MutationPending partagent le bras _ : la bannière de retour est absente et le bouton est activé ou désactivé par state.isPending. MutationFailure et MutationSuccess affichent chacun une bannière inline distincte. Aucun flag booléen, aucun appel setState.
Étapes de validation
L'exemple inclut un mode d'échec observable pour rendre le rollback visible :
- Ouvrir Liste d'utilisateurs et attendre le chargement initial
- Appuyer sur l'icône d'édition d'un utilisateur
- Saisir un nouveau nom et appuyer sur Sauvegarder: la liste se met à jour immédiatement en arrière-plan
- Attendre ~2 s pour la réponse du serveur :
- En cas de succès (~70 %) : le nouveau nom persiste ; une confirmation verte apparaît
- En cas d'échec (~30 %) : le nom revient à l'original ; une bannière rouge de rollback apparaît
- Appuyer sur l'icône de rafraîchissement dans l'app bar pour forcer une invalidation complète et observer l'état confirmé par le serveur
Code source
examples/ex_02_mutations_optimistic/
├── lib/
│ ├── main.dart ← configuration QoraClient + QoraScope
│ ├── models/user.dart ← classe de données pure avec copyWith
│ ├── services/fake_api.dart ← API simulée (délai 2 s, ~30 % d'échec sur updateUser)
│ └── screens/
│ ├── user_list_screen.dart ← QoraBuilder + déclencheur d'édition
│ └── rename_user_sheet.dart ← QoraMutationBuilder + protocole optimiste complet
└── pubspec.yaml
Voir aussi
- Recette de mises à jour optimistes : présentation conceptuelle pas à pas du même pattern
- Recette de mutations : référence API
QoraMutationBuilderetMutationOptions - QoraMutationBuilder : API widget complète
- Étude de cas : Liste et détail utilisateur :
QoraBuilder, cache SWR etFetchStatus
É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.
Étude de cas : Feed à défilement infini
Construire un feed de type réseau social haute performance avec pagination par curseur, gestion mémoire fenêtrée et création de post optimiste instantanée.