Usage avec Flutter

Bonnes pratiques

Patterns et conventions pour construire des applications Flutter fiables avec Qora : clés, placement des requêtes, câblage des mutations et performance.

Clés de requête

Utiliser des tableaux, pas des chaînes

Les clés de requête sont des tableaux. Le premier élément est un espace de noms de domaine ; les éléments suivants sont des paramètres.

main.dart
// Correct : structuré, invalidable par préfixe
['users', userId]
['users', userId, 'posts']
['posts', {'page': 2, 'tag': 'flutter'}]

// Éviter : opaque, difficile à invalider par groupe
'user-$userId'
'posts-page-2'

Centraliser les clés dans un fichier

Définir les clés à un seul endroit prévient les fautes de frappe et facilite l'invalidation en masse :

lib/data/query_keys.dart
abstract final class QueryKeys {
  static List<Object?> users() => ['users'];
  static List<Object?> user(int id) => ['users', id];
  static List<Object?> userPosts(int userId) => ['users', userId, 'posts'];
  static List<Object?> posts() => ['posts'];
  static List<Object?> post(int id) => ['posts', id];
}
main.dart
QoraBuilder<User>(
  queryKey: QueryKeys.user(userId),
  fetcher: () => api.getUser(userId),
  builder: (context, state, fetchStatus) { ... },
)

// Invalider toutes les requêtes utilisateur après une mise à jour du profil
client.invalidateWhere((key) => key.firstOrNull == 'users');

Référence rapide de l'API widget

WidgetParam cléParam fetcherSignature du builder
QoraBuilder<T>queryKeyfetcher(BuildContext, QoraState<T>, FetchStatus)
QoraStateBuilder<T>queryKey:(BuildContext, QoraState<T>)
QoraMutationBuilder<D,V,C>:mutator(BuildContext, MutationState<D,V>, mutate)
InfiniteQueryBuilder<D,P>queryKeyfetcher(BuildContext, InfiniteQueryState<D,P>, InfiniteQueryController<D,P>)
QoraPrefetch<T>queryKeyfetcher: (encapsule child)

Placement des fetchers

Garder les fetchers dans les classes repository, pas dans les widgets

lib/data/user_repository.dart
class UserRepository {
  final QoraClient _client;
  final UserApi _api;

  UserRepository(this._client, this._api);

  Stream<QoraState<User>> watchUser(int id) => _client.watchQuery(
        key: QueryKeys.user(id),
        fetcher: () => _api.getUser(id),
        options: const QoraOptions(staleTime: Duration(minutes: 5)),
      );

  Future<void> updateUser(int id, String name) async {
    final snapshot = _client.getQueryData<User>(QueryKeys.user(id));
    _client.setQueryData(QueryKeys.user(id), User(id: id, name: name));
    try {
      await _api.updateUser(id, name);
    } catch (_) {
      _client.restoreQueryData(QueryKeys.user(id), snapshot);
      rethrow;
    }
  }
}

Le code widget devient un simple binding :

user_screen.dart
QoraBuilder<User>(
  queryKey: QueryKeys.user(widget.userId),
  fetcher: () => repo.fetchUser(widget.userId),
  builder: (context, state, fetchStatus) { ... },
)

Recommandations pour staleTime

Configurer staleTime selon la fréquence de modification des données côté serveur :

Type de donnéesstaleTime
Prix ou flux en temps réelDuration.zero
Profil utilisateur / sessionDuration(minutes: 5)
Contenu d'articleDuration(minutes: 10)
Configuration applicativeDuration(hours: 1)
Données de référence statiquesDuration(days: 1)

Définir les valeurs par défaut globales dans QoraClientConfig et surcharger par requête uniquement si nécessaire :

main.dart
QoraClientConfig(
  defaultOptions: QoraOptions(staleTime: Duration(minutes: 5)),
)

FetchStatus : conscience du réseau

Le troisième argument de QoraBuilder.builder est FetchStatus. L'utiliser pour afficher un indicateur hors-ligne plutôt qu'un spinner :

user_screen.dart
QoraBuilder<User>(
  queryKey: ['users', userId],
  fetcher: () => api.getUser(userId),
  builder: (context, state, fetchStatus) {
    if (fetchStatus == FetchStatus.paused) {
      return OfflineBanner(staleData: state.dataOrNull);
    }
    return switch (state) {
      Success(:final data) => UserCard(data),
      Loading() => const CircularProgressIndicator(),
      Failure(:final error) => ErrorWidget('$error'),
      Initial() => const SizedBox.shrink(),
    };
  },
)

Éviter le sur-fetch

Utiliser QoraStateBuilder pour l'UI dérivée

QoraStateBuilder s'abonne à l'état d'une requête sans déclencher de fetch. À utiliser pour les composants qui affichent des données sans être responsables de leur chargement :

avatar_widget.dart
QoraStateBuilder<User>(
  queryKey: QueryKeys.user(userId),
  builder: (context, state) => switch (state) {
    Success(:final data) => Avatar(url: data.avatarUrl),
    _ => const DefaultAvatar(),
  },
)

Utiliser QoraPrefetch avant la navigation

QoraPrefetch est un widget qui préchauffe le cache dès son montage : aucun fetch ne se déclenche dans les callbacks de build :

user_list_item.dart
MouseRegion(
  onEnter: (_) => setState(() => _hover = true),
  child: _hover
      ? QoraPrefetch<User>(
          queryKey: QueryKeys.user(user.id),
          fetcher: () => api.getUser(user.id),
          child: UserListTile(user: user),
        )
      : UserListTile(user: user),
)

Mutations

Widget correct : QoraMutationBuilder

update_name_form.dart
QoraMutationBuilder<User, String, User?>(
  mutator: (name) => api.updateUser(userId, name),
  options: MutationOptions(
    onMutate: (name) async =>
        QoraScope.of(context).getQueryData<User>(['users', userId]),
    onError: (error, name, snapshot) async =>
        QoraScope.of(context).restoreQueryData(['users', userId], snapshot),
    onSuccess: (data, name, _) async {
      QoraScope.of(context).setQueryData(['users', userId], data);
      QoraScope.of(context).invalidateWhere((k) => k.firstOrNull == 'users');
    },
  ),
  builder: (context, state, mutate) => ElevatedButton(
    onPressed: state.isPending ? null : () => mutate(nameController.text),
    child: state.isPending
        ? const CircularProgressIndicator()
        : const Text('Sauvegarder'),
  ),
)

Utiliser retryCount: 0 sur les mutations

Les mutations ne sont pas idempotentes par défaut. Éviter les retries automatiques sauf si l'API est conçue pour les gérer :

main.dart
MutationOptions(retryCount: 0)

Performance

Placer QoraScope près de la racine

main.dart
final client = QoraClient(
  config: const QoraClientConfig(debugMode: kDebugMode),
);

runApp(
  QoraScope(
    client: client,
    lifecycleManager: FlutterLifecycleManager(qoraClient: client),
    connectivityManager: FlutterConnectivityManager(),
    child: const MyApp(),
  ),
);

QoraScope dispose automatiquement le client, le lifecycle manager et le connectivity manager au démontage : ne pas appeler client.dispose() manuellement quand QoraScope est utilisé.

Un seul client par application

Créer QoraClient (ou PersistQoraClient) une seule fois au démarrage et le réutiliser partout via QoraScope. Plusieurs instances ne partagent pas de cache.

Disposer les clients dans les tests

Dans les tests qui instancient un client directement (sans QoraScope), appeler client.dispose() dans tearDown :

test/user_test.dart
late QoraClient client;

setUp(() => client = QoraClient());
tearDown(() => client.dispose());

Persistance

Enregistrer les sérialiseurs avant hydrate()

main.dart
final client = PersistQoraClient(storage: storage);

client.registerSerializer<User>(
  QoraSerializer(toJson: (u) => u.toJson(), fromJson: User.fromJson),
  name: 'User', // nom stable : sûr avec --obfuscate
);

await client.hydrate();
runApp(...);

Toujours passer un name explicite

T.toString() est altéré par --obfuscate et la minification sur Flutter Web. Un name stable survit aux mises en production :

main.dart
client.registerSerializer<List<Post>>(serializer, name: 'List<Post>');

Voir aussi

Copyright © 2026