Concepts clés

États des requêtes

Les états des requêtes représentent le cycle de vie des opérations de récupération de données. Qora utilise des classes scellées pour fournir une gestion des états exhaustive et sûre au niveau des types, éliminant des catégories entières d'erreurs à l'exécution.

Vue d'ensemble

Toute requête dans Qora se trouve à chaque instant dans l'un des quatre états suivants :

lib/src/state/qora_state.dart
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

  1. previousData est conservé dans les états Loading et Failure. L'UI dispose toujours de quelque chose à afficher.
  2. Failure avec previousData n'est pas un état terminal. Il supporte la dégradation gracieuse.

Correspondance de motifs

switch exhaustif (recommandé)

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

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

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

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

user_page.dart
// 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
user_page.dart
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 :

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

user_page.dart
case Success(:final data, :final updatedAt):
  return Column(
    children: [
      UserCard(data),
      Text('Mis à jour ${_timeAgo(updatedAt)}'),
    ],
  );
main.dart
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 :

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

user_page.dart
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]),
      ),
    ],
  );
main.dart
state.isError     // true (alias : hasError)
state.hasData     // true quand previousData != null
state.dataOrNull  // retourne previousData
state.errorOrNull // retourne l'objet error
Toujours vérifier 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

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

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

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

main.dart
// 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 :

user_page.dart
// É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 :

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

test/user_repository_test.dart
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

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

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

Copyright © 2026