Guides Pratiques

Mutations

Effectuer des opérations d'écriture (POST, PUT, DELETE) avec un support complet du cycle de vie : mises à jour optimistes, rollback automatique, retry et observabilité DevTools.

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

post_repository.dart
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 :

post_repository.dart
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});
    },
  ),
);
HookQuandReçoit
onMutate(variables)Avant le démarrage du mutatorTVariables
onSuccess(data, variables, context)Après un mutator réussiTData, TVariables, TContext?
onError(error, variables, context)Après une exception du mutatorObject, TVariables, TContext?
onSettled(data, error, variables, context)Après l'un ou l'autre résultatTData?, 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 :

  1. onMutate : snapshot des données actuelles du cache, application du changement optimiste, retour du snapshot en tant que TContext
  2. onError : utilisation du snapshot (TContext) pour le rollback via client.restoreQueryData()
  3. onSuccess : invalidation de la requête pour récupérer les données confirmées par le serveur
post_repository.dart
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.

post_repository.dart
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 :

post_repository.dart
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 :

checkout_service.dart
// 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.

post_repository.dart
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é :

post_repository.dart
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

Copyright © 2026