Guides Pratiques

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.

Une mise à jour optimiste applique un changement d'état local avant la confirmation serveur. Si la requête échoue, les données précédentes sont restaurées.

Qora fournit trois primitives qui rendent ce pattern simple :

MéthodeDescription
client.getQueryData<T>(key)Snapshot des données courantes (T?), ou null si hors état Success
client.setQueryData<T>(key, data)Pousser des données directement dans le cache : tous les widgets abonnés reconstruisent immédiatement
client.restoreQueryData<T>(key, snapshot)Restaurer une valeur de données précédemment snapshotée ; null supprime l'entrée

Le pattern

update_user.dart
Future<void> updateUserName(BuildContext context, int userId, String newName) async {
  final client = context.qora;
  final key = ['users', userId];

  // 1. Snapshot des données courantes pour le rollback
  final previousUser = client.getQueryData<User>(key);

  try {
    // 2. Appliquer la mise à jour optimiste : l'UI reflète le changement instantanément
    if (previousUser != null) {
      client.setQueryData<User>(key, previousUser.copyWith(name: newName));
    }

    // 3. Déclencher la mutation
    final updatedUser = await UserApi.updateUser(userId, newName);

    // 4. Confirmer avec la réponse serveur autoritaire
    client.setQueryData<User>(key, updatedUser);

    // 5. Invalider les requêtes liées (ex. la liste qui affiche le nom)
    client.invalidateWhere((key) => key.firstOrNull == 'users');
  } catch (error) {
    // 6. Rollback aux données pré-mutation
    client.restoreQueryData<User>(key, previousUser);

    if (context.mounted) {
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text('Échec de la mise à jour : $error')),
      );
    }
  }
}

Étape par étape

ÉtapeAppelEffet
1getQueryData(key)Retourne la valeur T? courante, ou null si absente du cache
2setQueryData(key, optimisticData)Cache mis à jour → tous les QoraBuilder actifs reconstruisent
3UserApi.updateUser(...)Requête réseau en cours
4setQueryData(key, serverData)Cache confirmé avec les données autoritaires du serveur
5invalidateWhere(...)Les requêtes liées (ex. la liste utilisateurs) seront refetchées
6restoreQueryData(key, previousUser)Cache annulé → l'UI revient à l'état pré-mutation

Exemple complet : écran d'édition de profil

screens/edit_profile_screen.dart
class EditProfileScreen extends StatefulWidget {
  final int userId;

  const EditProfileScreen({super.key, required this.userId});

  @override
  State<EditProfileScreen> createState() => _EditProfileScreenState();
}

class _EditProfileScreenState extends State<EditProfileScreen> {
  late final TextEditingController _nameController;
  bool _isSaving = false;

  @override
  void initState() {
    super.initState();
    final currentUser = context.qora.getQueryData<User>(['users', widget.userId]);
    _nameController = TextEditingController(text: currentUser?.name ?? '');
  }

  @override
  void dispose() {
    _nameController.dispose();
    super.dispose();
  }

  Future<void> _save() async {
    if (_isSaving) return;
    setState(() => _isSaving = true);

    final client = context.qora;
    final key = ['users', widget.userId];
    final previousUser = client.getQueryData<User>(key);

    try {
      // Mise à jour optimiste
      if (previousUser != null) {
        client.setQueryData<User>(
          key,
          previousUser.copyWith(name: _nameController.text),
        );
      }

      final updated = await UserApi.updateUser(
        widget.userId,
        _nameController.text,
      );

      client.setQueryData<User>(key, updated);
      client.invalidateWhere((k) => k.firstOrNull == 'users');

      if (mounted) Navigator.pop(context);
    } catch (error) {
      client.restoreQueryData<User>(key, previousUser);

      if (mounted) {
        ScaffoldMessenger.of(context).showSnackBar(
          SnackBar(content: Text('Sauvegarde échouée : $error')),
        );
        setState(() => _isSaving = false);
      }
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Modifier le profil'),
        actions: [
          TextButton(
            onPressed: _isSaving ? null : _save,
            child: _isSaving
                ? const SizedBox(
                    width: 16,
                    height: 16,
                    child: CircularProgressIndicator(strokeWidth: 2),
                  )
                : const Text('Sauvegarder'),
          ),
        ],
      ),
      body: Padding(
        padding: const EdgeInsets.all(16),
        child: TextField(
          controller: _nameController,
          decoration: const InputDecoration(labelText: 'Nom'),
        ),
      ),
    );
  }
}

Quand l'utilisateur appuie sur Sauvegarder :

  1. L'UserDetailScreen (qui utilise QoraBuilder<User>) reflète immédiatement le nouveau nom : sans spinner, sans délai.
  2. La requête réseau se déclenche en arrière-plan.
  3. En cas de succès, la réponse serveur confirme les données.
  4. En cas d'échec, le nom revient à ce qu'il était avant l'appui.

Invalider les requêtes liées

Après une mutation réussie, il est généralement souhaitable d'invalider les requêtes liées pour rafraîchir les vues de liste périmées. Utiliser invalidateWhere avec un prédicat :

update_user.dart
// Invalider l'utilisateur spécifique ET la liste des utilisateurs
client.invalidateWhere((key) => key.firstOrNull == 'users');

// Plus ciblé : uniquement la liste, pas l'entrée individuelle
client.invalidateWhere((key) => key.length == 1 && key.firstOrNull == 'users');

// Ou invalider la clé exacte
client.invalidate(['users']);

Les widgets QoraBuilder actifs pour toute clé correspondante reçoivent Loading(previousData: data) et relancent immédiatement un fetch. Comme les données précédentes sont portées, il n'y a pas de flash de contenu vide.


Pourquoi restoreQueryData plutôt que setQueryData ?

Les deux méthodes écrivent des données dans le cache. La différence réside dans la gestion du null :

  • setQueryData<T>(key, data): requiert une valeur T non nulle ; produit toujours un état Success.
  • restoreQueryData<T>(key, snapshot): accepte T? ; quand snapshot est null, appelle removeQuery(key) au lieu d'écrire. Cela rend le rollback sûr quand la requête n'avait pas de données antérieures : aucun état Success orphelin n'est laissé derrière.
rollback.dart
// Correct : gère le cas null (requête sans données antérieures)
client.restoreQueryData<User>(key, previousUser); // null → removeQuery

// Attention : Équivalent uniquement quand previousUser est garanti non-null
if (previousUser != null) {
  client.setQueryData<User>(key, previousUser);
}

Bonnes pratiques

À faire

update.dart
// Toujours faire un snapshot avant de muter
final previousUser = client.getQueryData<User>(key);

// Appliquer la mise à jour optimiste uniquement si les données existent
if (previousUser != null) {
  client.setQueryData(key, optimisticData);
}

// Toujours restaurer sur tout chemin d'erreur
} catch (_) {
  client.restoreQueryData(key, previousUser);
}

À éviter

update.dart
// Ne pas omettre le snapshot : impossible de faire un rollback
client.setQueryData(key, optimisticData);
await api.update(data); // si cela lève une exception, pas de rollback possible

// Ne pas ignorer l'erreur : toujours informer l'utilisateur
} catch (_) {} // Incorrect : Un échec silencieux est pire qu'aucune mise à jour optimiste

Étapes suivantes

Copyright © 2026