Bonnes pratiques
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.
// 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 :
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];
}
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
| Widget | Param clé | Param fetcher | Signature du builder |
|---|---|---|---|
QoraBuilder<T> | queryKey | fetcher | (BuildContext, QoraState<T>, FetchStatus) |
QoraStateBuilder<T> | queryKey | : | (BuildContext, QoraState<T>) |
QoraMutationBuilder<D,V,C> | : | mutator | (BuildContext, MutationState<D,V>, mutate) |
InfiniteQueryBuilder<D,P> | queryKey | fetcher | (BuildContext, InfiniteQueryState<D,P>, InfiniteQueryController<D,P>) |
QoraPrefetch<T> | queryKey | fetcher | : (encapsule child) |
Placement des fetchers
Garder les fetchers dans les classes repository, pas dans les widgets
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 :
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ées | staleTime |
|---|---|
| Prix ou flux en temps réel | Duration.zero |
| Profil utilisateur / session | Duration(minutes: 5) |
| Contenu d'article | Duration(minutes: 10) |
| Configuration applicative | Duration(hours: 1) |
| Données de référence statiques | Duration(days: 1) |
Définir les valeurs par défaut globales dans QoraClientConfig et surcharger par requête uniquement si nécessaire :
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 :
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 :
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 :
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
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 :
MutationOptions(retryCount: 0)
Performance
Placer QoraScope près de la racine
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 :
late QoraClient client;
setUp(() => client = QoraClient());
tearDown(() => client.dispose());
Persistance
Enregistrer les sérialiseurs avant hydrate()
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 :
client.registerSerializer<List<Post>>(serializer, name: 'List<Post>');
Voir aussi
Widgets de statut réseau
NetworkStatusBuilder et NetworkStatusIndicator : widgets réactifs pour afficher l'état hors-ligne dans les applications Flutter.
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.