Mutations
Une mutation est toute opération qui modifie l'état côté serveur, y compris les opérations de création, mise à jour et suppression. Les requêtes sont en lecture seule et pilotées par le cache. Les mutations sont des opérations d'écriture explicites.
Qora gère les mutations avec MutationController, un contrôleur de cycle de vie autonome sans clé de cache, sans déduplication et sans comportement SWR.
La machine d'état
MutationIdle
│
│ mutate(variables)
▼
MutationPending
│
├── success ──▶ MutationSuccess
│
└── failure ──▶ MutationFailure
│
└── reset() ──▶ MutationIdle
Chaque état porte variables : la valeur passée à mutate(). Cela facilite l'affichage de "Création du post…" ou "Échec de la suppression du commentaire #42" sans état local supplémentaire.
Utilisation de base
final controller = MutationController<Post, String, void>(
mutator: (title) => api.createPost(title),
);
// Déclencher la mutation
final post = await controller.mutate('Bonjour, Qora !');
// Retourne le Post en cas de succès, ou null en cas d'échec.
// Observer les transitions d'état
controller.stream.listen((state) {
switch (state) {
case MutationIdle():
print('Prêt');
case MutationPending(:final variables):
print('Création de "$variables"…');
case MutationSuccess(:final data):
print('Créé : ${data.title}');
case MutationFailure(:final error):
print('Échec : $error');
}
});
// Réinitialiser à idle après affichage du résultat
controller.reset();
// Toujours disposer quand terminé
controller.dispose();
mutate() ne lève jamais d'exception. Les erreurs sont capturées dans l'état MutationFailure et retournées sous forme de null au site d'appel.
Callbacks de cycle de vie
MutationOptions fournit quatre hooks qui s'exécutent autour de la mutation :
final controller = MutationController<Post, String, void>(
mutator: (title) => api.createPost(title),
options: MutationOptions(
invalidates: [['posts']], // Rafraîchir automatique la liste des posts en cas de succès
onMutate: (title) async {
print('Sur le point de créer "$title"');
// La valeur de retour devient TContext : passée à onSuccess, onError, onSettled.
},
onSuccess: (post, title, _) async {
print('Créé : ${post.id}');
},
onError: (error, title, _) async {
print('Échec de la création de "$title" : $error');
},
onSettled: (post, error, title, _) async {
// Appelé quel que soit le résultat.
analytics.track('post_create_attempt', {'success': error == null});
},
),
);
| Hook | Quand | Reçoit |
|---|---|---|
onMutate(variables) | Avant le démarrage du mutator | TVariables |
onSuccess(data, variables, context) | Après un mutator réussi | TData, TVariables, TContext? |
onError(error, variables, context) | Après une exception du mutator | Object, TVariables, TContext? |
onSettled(data, error, variables, context) | Après l'un ou l'autre résultat | TData?, Object?, TVariables, TContext? |
La valeur retournée par onMutate devient TContext : le troisième paramètre de type de MutationController. C'est le mécanisme derrière le rollback des mises à jour optimistes.
Invalidation automatique
Au lieu d'appeler client.invalidate() manuellement dans onSuccess, déclarez les clés de requête à l'avance avec invalidates :
options: MutationOptions(
invalidates: [['posts'], ['users', userId]],
)
Chaque clé est invalidée automatiquement après la réussite de la mutation, qu'elle s'exécute immédiatement ou soit rejouée depuis la file hors-ligne. Les invalidations ont lieu après l'exécution de onSuccess, afin que le cache soit rafraîchi après les effets de bord manuels.
// Avant : manuel
onSuccess: (_, __, ___) async { client.invalidate(['posts']); }
// Après : automatique
invalidates: [['posts']],
Invalidation par tags
Vous pouvez aussi invalider les requêtes par tag, en les découplant complètement de la structure des clés :
options: MutationOptions(
invalidatesTags: [QueryTag('post'), QueryTag('user', userId)],
)
Toute requête dont QoraOptions.providesTags contient un tag correspondant est automatiquement revalidée. Correspondance générique : QueryTag('post') revalide toutes les requêtes fournissant un tag 'post' quel que soit l'identifiant. Correspondance spécifique : QueryTag('post', '123') revalide uniquement les requêtes fournissant exactement 'post':'123'.
// Avant : par clé
invalidates: [['posts']],
// Après : par tag
invalidatesTags: [QueryTag('post')],
Fonctionne avec QoraMutationBuilder, useMutation et le MutationController bru, tant qu'un QoraClient ou un callback invalidateQuery est câblé. invalidates et invalidatesTags peuvent être utilisés ensemble ; l'invalidation par tag s'exécute après celle par clé.
Mises à jour optimistes
Les mises à jour optimistes appliquent un changement au cache local immédiatement : avant que le serveur le confirme. Si le serveur rejette le changement, les données précédentes sont restaurées.
Le pattern comporte trois étapes :
onMutate: snapshot des données actuelles du cache, application du changement optimiste, retour du snapshot en tant queTContextonError: utilisation du snapshot (TContext) pour le rollback viaclient.restoreQueryData()onSuccess: invalidation de la requête pour récupérer les données confirmées par le serveur
final controller = MutationController<Post, String, List<Post>?>(
// TContext = List<Post>? : le snapshot utilisé pour le rollback
mutator: (title) => api.createPost(title),
options: MutationOptions(
invalidates: [['posts']], // 4️⃣ Confirmation : remplacer les données optimistes par les vraies données serveur
onMutate: (title) async {
// 1️⃣ Snapshot
final previous = client.getQueryData<List<Post>>(['posts']);
// 2️⃣ Mise à jour optimiste : l'UI reflète le changement immédiatement
client.setQueryData<List<Post>>(
['posts'],
[...?previous, Post.optimistic(title)],
);
return previous; // Devient TContext
},
onError: (error, title, previous) async {
// 3️⃣ Rollback : restaurer le snapshot en cas d'échec
client.restoreQueryData(['posts'], previous);
},
),
);
await controller.mutate('Nouveau Post');
client.restoreQueryData(key, null) supprime entièrement la requête du cache : utile quand le snapshot était null (la liste n'existait pas encore).Gestion des échecs de onMutate
Si onMutate lève une exception (par exemple une validation pré-appel échoue), le mutator n'est pas appelé. Le contrôleur passe directement à MutationFailure.
options: MutationOptions(
onMutate: (title) async {
if (title.isEmpty) throw ArgumentError('Le titre ne peut pas être vide');
// Le mutator ne s'exécutera jamais si cela lève une exception
return null;
},
),
Utile pour les validations côté client qui doivent bloquer entièrement l'appel réseau.
Retry
Par défaut, les mutations ne font pas de retry (contrairement aux requêtes, relancer une écriture peut avoir des effets secondaires). Activer le retry explicitement :
options: const MutationOptions(
retryCount: 2, // 1 initial + 2 retries = 3 tentatives au total
retryDelay: Duration(seconds: 1), // Backoff exponentiel : 1s, 2s
),
Le retry s'applique uniquement au mutator. Si onMutate lève une exception, aucun retry ne se produit.
Mode autonome
MutationController fonctionne sans QoraClient. Passer tracker: null (valeur par défaut) pour opérer en mode autonome sans observabilité globale :
// Pas de client requis : les mutations fonctionnent partout
final controller = MutationController<Order, Cart, void>(
mutator: (cart) => paymentApi.checkout(cart),
);
Observabilité DevTools
Câbler un contrôleur dans QoraClient en passant tracker: client. Le client expose alors toute l'activité des mutations via mutationEvents et activeMutations.
final controller = MutationController<Post, String, void>(
mutator: (title) => api.createPost(title),
tracker: client,
// Optionnel : étiqueter ce contrôleur pour le filtrage DevTools
metadata: {'category': 'content', 'resource': 'post'},
);
// Écouter toute l'activité des mutations sur tous les contrôleurs suivis
client.mutationEvents.listen((event) {
if (event.isError) {
logger.error('[${event.mutatorId}] Échec', event.error);
}
});
// Snapshot des mutations en cours (uniquement les entrées Pending)
final running = client.activeMutations;
print('${running.length} mutation(s) en cours');
activeMutations ne contient que les entrées MutationPending : les mutations terminées (Success ou Failure) sont purgées automatiquement. Cela prévient l'accumulation mémoire et fait de la map un indicateur fiable "actuellement en vol".Lors de l'utilisation de QoraMutationBuilder dans Flutter, tracker est automatiquement défini à QoraScope.maybeOf(context) : aucun câblage manuel n'est nécessaire.
Mises à jour optimistes multi-requêtes
Une seule mutation peut toucher plusieurs requêtes. Chaque appel à setQueryData déclenche une mise à jour réactive dans tout QoraBuilder abonné à cette clé :
options: MutationOptions(
onMutate: (postId) async {
// Snapshot des deux requêtes
final posts = client.getQueryData<List<Post>>(['posts']);
final user = client.getQueryData<User>(['users', currentUserId]);
// Mise à jour optimiste des deux
client.setQueryData<List<Post>>(
['posts'],
posts?.where((p) => p.id != postId).toList(),
);
client.setQueryData<User>(
['users', currentUserId],
user?.copyWith(postCount: (user.postCount ?? 1) - 1),
);
return (posts: posts, user: user); // TContext est un record
},
onError: (error, postId, snapshots) async {
// Rollback des deux
client.restoreQueryData(['posts'], snapshots?.posts);
client.restoreQueryData(['users', currentUserId], snapshots?.user);
},
),
Étapes suivantes
- MutationBuilder : Lier les mutations à l'UI Flutter
- Mises à jour optimistes : Exploration approfondie des patterns de rollback
- Référence API Mutation : Documentation API complète
Bonnes pratiques
Patterns et conventions pour construire des applications Flutter fiables avec Qora : clés, placement des requêtes, câblage des mutations et performance.
Mises à jour optimistes
Mettre à jour l'UI instantanément pendant que les mutations sont en cours, et effectuer un rollback sécurisé en cas d'échec : sans bibliothèques supplémentaires.