États des requêtes
Vue d'ensemble
Toute requête dans Qora se trouve à chaque instant dans l'un des quatre états suivants :
sealed class QoraState<T> { ... }
// La requête n'a pas encore démarré
final class Initial<T> extends QoraState<T> {}
// Fetch actif (avec données périmées optionnelles)
final class Loading<T> extends QoraState<T> {
final T? previousData;
}
// Données récupérées avec succès
final class Success<T> extends QoraState<T> {
final T data;
final DateTime updatedAt;
}
// Fetch échoué (avec données périmées optionnelles pour la dégradation gracieuse)
final class Failure<T> extends QoraState<T> {
final Object error;
final StackTrace? stackTrace;
final T? previousData;
}
Les classes scellées Dart 3 garantissent la correspondance de motifs exhaustive à la compilation : le compilateur rejette tout switch qui ne traite pas les quatre variantes.
Transitions d'état
Initial
│ premier fetch déclenché
▼
Loading(previousData: null)
│ fetcher réussit
▼
Success(data, updatedAt)
│ staleTime écoulé → refetch en arrière-plan
▼
Loading(previousData: data) ← données périmées visibles pendant le rechargement
│ fetcher réussit
▼
Success(newData, updatedAt)
│ fetcher échoue après tous les retries
▼
Failure(error, previousData: data) ← données périmées toujours accessibles
Principes fondamentaux
previousDataest conservé dans les étatsLoadingetFailure. L'UI dispose toujours de quelque chose à afficher.FailureavecpreviousDatan'est pas un état terminal. Il supporte la dégradation gracieuse.
Correspondance de motifs
switch exhaustif (recommandé)
Widget build(BuildContext context) {
return switch (state) {
Initial() => const SizedBox.shrink(),
Loading(:final previousData) => previousData != null
? Stack(children: [UserCard(previousData), const LinearProgressIndicator()])
: const CircularProgressIndicator(),
Success(:final data) => UserCard(data),
Failure(:final error, :final previousData) => Column(
children: [
if (previousData != null) UserCard(previousData),
ErrorBanner('$error'),
],
),
};
}
when : dispatch d'effets de bord
Les quatre callbacks sont optionnels. when retourne void et est conçu pour les effets de bord : événements analytics, snackbars, logging :
state.when(
onSuccess: (data, updatedAt) => analytics.track('loaded', {'id': data.id}),
onError: (error, stackTrace, previousData) => showSnackBar('$error'),
);
maybeWhen : mapping vers une valeur avec fallback
maybeWhen retourne une valeur de type R. Il accepte un fallback orElse et des handlers optionnels par état :
final widget = state.maybeWhen(
onSuccess: (data, _) => UserCard(data),
orElse: () => const CircularProgressIndicator(),
);
Initial
La requête n'a jamais été exécutée. C'est l'état par défaut de toute clé qui n'a pas encore été chargée.
final state = client.getQueryState<User>(['users', 42]);
print(state); // Initial<User>()
print(state.isInitial); // true
print(state.hasData); // false
print(state.dataOrNull); // null
Afficher un espace réservé ou rien. Un spinner est inapproprié ici car aucun fetch n'est en cours.
Loading
Un fetch est activement en cours. previousData contient les dernières données réussies (le cas échéant), permettant à l'UI de rester utile pendant les rechargements.
// Premier chargement : pas de previousData
Loading(previousData: null) // Afficher un spinner plein écran
// Refetch / rechargement en arrière-plan : previousData disponible
Loading(previousData: User(id: 1, name: 'Alice')) // Afficher l'UI périmée + indicateur discret
case Loading(:final previousData):
if (previousData != null) {
return Stack(
children: [
UserCard(previousData),
const Positioned(
top: 0, left: 0, right: 0,
child: LinearProgressIndicator(),
),
],
);
}
return const CircularProgressIndicator();
Propriétés utilitaires :
state.isLoading // true
state.isFirstLoad // true quand previousData == null
state.isRefreshing // true quand previousData != null
state.hasData // true quand previousData != null
state.dataOrNull // retourne previousData
Success
Les données ont été récupérées avec succès. Contient data et updatedAt pour le suivi de la fraîcheur.
case Success(:final data, :final updatedAt):
return Column(
children: [
UserCard(data),
Text('Mis à jour ${_timeAgo(updatedAt)}'),
],
);
final s = state as Success<User>;
s.data // l'User récupéré
s.updatedAt // DateTime du dernier fetch réussi
s.age // Duration depuis updatedAt
s.isStale(Duration(minutes: 5)) // true si age > 5 min
Propriétés utilitaires :
state.isSuccess // true
state.hasData // true
state.dataOrNull // retourne data
state.successDataOrNull // retourne data (null pour les états non-Success)
state.updatedAt // DateTime?
Failure
Le fetch a échoué après toutes les tentatives de retry. previousData donne accès aux dernières données réussies pour la dégradation gracieuse.
case Failure(:final error, :final previousData):
return Column(
children: [
if (previousData != null) ...[
UserCard(previousData),
const SizedBox(height: 8),
],
ErrorBanner(
message: '$error',
onRetry: () => client.invalidate(['users', userId]),
),
],
);
state.isError // true (alias : hasError)
state.hasData // true quand previousData != null
state.dataOrNull // retourne previousData
state.errorOrNull // retourne l'objet error
previousData dans Failure. Afficher les données périmées à côté d'une bannière d'erreur offre une meilleure expérience qu'un écran d'erreur vide.Extensions de state
Qora fournit un ensemble d'extensions sur QoraState<T> définies dans state_extensions.dart.
Accès aux données
state.hasData // true pour Success, ou Loading/Failure avec previousData
state.dataOrNull // T? : meilleures données disponibles dans tous les états
state.requireData() // T : lève StateError si aucune donnée
state.successDataOrNull // T? : uniquement pour l'état Success, null sinon
Indicateurs de statut
state.isInitial // true si Initial
state.isLoading // true si Loading
state.isSuccess // true si Success
state.isError // true si Failure
state.hasError // alias de isError
state.isFirstLoad // true si Loading sans previousData
state.isRefreshing // true si Loading avec previousData
Mapping
// Transformer le type de données, en préservant la structure d'état
final namesState = usersState.map((users) => users.map((u) => u.name).toList());
// usersState: Success<List<User>> → namesState: Success<List<String>>
// mapData se comporte comme map ; choisissez le nom le plus lisible dans une chaîne
final countState = usersState.mapData((users) => users.length);
// Fold exhaustif vers une valeur unique
final label = state.fold(
onInitial: () => 'Non chargé',
onLoading: (prev) => prev != null ? 'Actualisation...' : 'Chargement...',
onSuccess: (data, _) => 'Chargé : ${data.name}',
onError: (error, stackTrace, prev) => 'Erreur : $error',
);
Combinaison de plusieurs états
// Combiner deux états : Success uniquement quand les deux sont Success
final combined = userState.combine(
postsState,
(user, posts) => UserWithPosts(user, posts),
);
// Combiner une liste d'états
final allUsers = QoraStateUtils.combineList([state1, state2, state3]);
// Combiner exactement 2 ou 3 états en un record
final pair = QoraStateUtils.combine2(userState, settingsState);
final triple = QoraStateUtils.combine3(userState, postsState, tagsState);
Extensions de stream
watchQuery retourne un Stream<QoraState<T>> avec des opérateurs supplémentaires définis dans QoraStateStreamExtensions :
// Émettre uniquement les états qui ont des données
client.watchQuery(...).whereHasData().listen(...);
// Émettre uniquement les états Success
client.watchQuery(...).whereSuccess().listen((s) => print(s.data));
// Émettre uniquement les états Failure
client.watchQuery(...).whereError().listen((f) => logError(f.error));
// Extraire les données comme T? (null pour les états sans données)
client.watchQuery(...).dataOrNull().listen(...);
// Extraire les données : ignorer les états sans données
client.watchQuery(...).data().listen((user) => render(user));
// Mapper le type de données
client.watchQuery<List<User>>(...).mapData((u) => u.length);
// Retarder les émissions Loading pour éviter le scintillement sur les connexions rapides
client.watchQuery(...).delayLoading(Duration(milliseconds: 300));
Sérialisation
Persister et restaurer les états de requête entre les sessions avec QoraStateCodec :
final codec = QoraStateCodec<User>(
encode: (user) => user.toJson(),
decode: (json) => User.fromJson(json as Map<String, dynamic>),
);
// Sauvegarder en stockage
final json = codec.encodeState(state);
await prefs.setString('user_state', jsonEncode(json));
// Restaurer depuis le stockage
final jsonStr = prefs.getString('user_state');
if (jsonStr != null) {
final restored = codec.decodeState(jsonDecode(jsonStr) as Map<String, dynamic>);
client.setQueryData(['users', userId], (restored as Success<User>).data);
}
Tests
import 'package:test/test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:qora/qora.dart';
class MockUserApi extends Mock implements UserApi {}
void main() {
late QoraClient client;
late MockUserApi api;
setUp(() {
api = MockUserApi();
client = QoraClient();
});
tearDown(() => client.dispose());
test('transitions vers Success sur un fetch réussi', () async {
when(() => api.getUser(1)).thenAnswer((_) async => User(id: 1, name: 'Alice'));
final states = <QoraState<User>>[];
final sub = client
.watchQuery<User>(key: ['users', 1], fetcher: () => api.getUser(1))
.listen(states.add);
await Future<void>.delayed(Duration.zero);
await sub.cancel();
expect(states, [
isA<Initial<User>>(),
isA<Loading<User>>(),
isA<Success<User>>(),
]);
expect((states.last as Success<User>).data.name, 'Alice');
});
test('transitions vers Failure sur erreur après retries', () async {
when(() => api.getUser(1)).thenThrow(Exception('Erreur réseau'));
final failClient = QoraClient(
config: const QoraClientConfig(
defaultOptions: QoraOptions(retryCount: 0),
),
);
addTearDown(failClient.dispose);
final states = <QoraState<User>>[];
final sub = failClient
.watchQuery<User>(key: ['users', 1], fetcher: () => api.getUser(1))
.listen(states.add);
await Future<void>.delayed(Duration.zero);
await sub.cancel();
expect(states.last, isA<Failure<User>>());
});
}
Bonnes pratiques
À faire
// Traiter les quatre états de façon exhaustive
switch (state) {
case Initial(): return const Placeholder();
case Loading(): return const Spinner();
case Success(): return DataWidget(state.data);
case Failure(): return ErrorWidget(state.error);
}
// Utiliser previousData dans les états Loading et Failure
case Failure(:final error, :final previousData):
return previousData != null
? DataWidget(previousData)
: ErrorScreen(error);
À éviter
// Gestion partielle des états
if (state is Success) render(state.data); // Failure est ignoré silencieusement
// Cast non sécurisé sans vérification de type préalable
final data = (state as Success<User>).data; // Lève une exception sur Loading ou Failure
Étapes suivantes
- Cache : Éviction LRU, timers GC et cycle de vie du cache
- Stale-While-Revalidate : Revalidation en arrière-plan
- Référence API QoraState : Documentation API complète
Clés de requête
Les clés de requête sont le fondement du système de cache de Qora. Elles identifient les requêtes de façon unique et permettent la gestion automatique du cache, l'invalidation et le refetch.
Cache
Le cache de Qora permet un accès instantané aux données. Apprenez comment les entrées de cache sont créées, vieillissent et sont collectées par le GC, comment le partage structurel évite les reconstructions inutiles, et comment contrôler chaque comportement.