Mises à jour optimistes
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éthode | Description |
|---|---|
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
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
| Étape | Appel | Effet |
|---|---|---|
| 1 | getQueryData(key) | Retourne la valeur T? courante, ou null si absente du cache |
| 2 | setQueryData(key, optimisticData) | Cache mis à jour → tous les QoraBuilder actifs reconstruisent |
| 3 | UserApi.updateUser(...) | Requête réseau en cours |
| 4 | setQueryData(key, serverData) | Cache confirmé avec les données autoritaires du serveur |
| 5 | invalidateWhere(...) | Les requêtes liées (ex. la liste utilisateurs) seront refetchées |
| 6 | restoreQueryData(key, previousUser) | Cache annulé → l'UI revient à l'état pré-mutation |
Exemple complet : écran d'édition de profil
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 :
- L'
UserDetailScreen(qui utiliseQoraBuilder<User>) reflète immédiatement le nouveau nom : sans spinner, sans délai. - La requête réseau se déclenche en arrière-plan.
- En cas de succès, la réponse serveur confirme les données.
- 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 :
// 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 valeurTnon nulle ; produit toujours un étatSuccess.restoreQueryData<T>(key, snapshot): accepteT?; quandsnapshotestnull, appelleremoveQuery(key)au lieu d'écrire. Cela rend le rollback sûr quand la requête n'avait pas de données antérieures : aucun étatSuccessorphelin n'est laissé derrière.
// 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
// 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
// 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
- Démarrage rapide : Écrans de liste et de détail sans mutations
- API QoraClient : Référence de
setQueryData,getQueryData,restoreQueryData - États des requêtes : Comprendre la machine d'état qui rend le rollback prévisible
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.
Persistance
Persister les résultats des requêtes sur disque et les restaurer au démarrage : donnant à l'application une couche de données instantanée qui fonctionne entièrement hors-ligne.