Exemples

É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.

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 du MutationController (création au montage, dispose au démontage), câble la file hors ligne automatiquement
  • MutationOptions.onMutate : capture un snapshot de cache pré-mutation (TContext) et applique setQueryData avant l'appel serveur
  • MutationOptions.onError : appelle restoreQueryData avec le snapshot TContext pour annuler simultanément les deux entrées de cache affectées
  • MutationOptions.onSuccess : appelle invalidateWhere pour 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 via TContext
  • 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é :

lib/screens/user_list_screen.dart
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ès
  • TVariables = ({String id, String name}): entrée du mutateur
  • TContext = ({User? user, List<User>? list}): snapshot pré-mutation pour le rollback
lib/screens/rename_user_sheet.dart
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 :

lib/screens/rename_user_sheet.dart
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 :

lib/screens/rename_user_sheet.dart
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 :

lib/screens/rename_user_sheet.dart
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 :

lib/screens/rename_user_sheet.dart
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 :

  1. Ouvrir Liste d'utilisateurs et attendre le chargement initial
  2. Appuyer sur l'icône d'édition d'un utilisateur
  3. Saisir un nouveau nom et appuyer sur Sauvegarder: la liste se met à jour immédiatement en arrière-plan
  4. 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
  5. 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 le code source complet


Voir aussi

Copyright © 2026