Clés de requête
Vue d'ensemble
Une clé de requête est un tableau sérialisable que Qora utilise pour :
- Mettre en cache les données de façon unique par requête
- Déclencher la revalidation en arrière-plan quand les clés changent
- Partager l'état du cache entre services et widgets
- Invalider et mettre à jour les requêtes liées après les mutations
Qora supporte deux syntaxes :
// Liste directe (sans friction)
client.fetchQuery(key: ['users', userId], fetcher: () => api.getUser(userId));
// Wrapper typé (sûreté du typage)
client.fetchQuery(key: QoraKey.withId('users', userId), fetcher: () => api.getUser(userId));
Les deux formes sont 100% interchangeables. Elles se résolvent en la même représentation interne.
Bases des clés de requête
Clés simples
// Récupérer tous les utilisateurs
final users = await client.fetchQuery<List<User>>(
key: ['users'],
fetcher: fetchUsers,
);
// Récupérer les paramètres de l'application
final settings = await client.fetchQuery<Settings>(
key: ['settings'],
fetcher: fetchSettings,
);
Clés avec variables
// Récupérer un utilisateur spécifique
final user = await client.fetchQuery<User>(
key: ['users', userId],
fetcher: () => fetchUser(userId),
);
// Récupérer des posts filtrés
final posts = await client.fetchQuery<List<Post>>(
key: ['posts', {'status': 'published', 'author': authorId}],
fetcher: () => fetchPosts(status: 'published', author: authorId),
);
Clés hiérarchiques
// Profil utilisateur
['users', userId, 'profile']
// Posts d'un utilisateur
['users', userId, 'posts']
// Commentaires d'un post spécifique
['posts', postId, 'comments']
// Données paginées
['posts', {'page': 2, 'limit': 20}]
Égalité des clés
Qora utilise l'égalité profonde pour comparer les clés. Deux clés sont égales si leur contenu est identique, et non leur référence.
Valeurs primitives
['users', 1] == ['users', 1] // true
['users', 1] == ['users', 2] // false
Listes et imbrication
['users', [1, 2, 3]] == ['users', [1, 2, 3]] // true
['users', [1, 2]] == ['users', [2, 1]] // false (l'ordre importe)
Maps (indépendant de l'ordre)
Les maps sont comparées par leur contenu, quel que soit l'ordre d'insertion des clés :
['posts', {'status': 'active', 'limit': 10}]
==
['posts', {'limit': 10, 'status': 'active'}] // true
Ce comportement diffère de TanStack Query (JavaScript), où l'ordre des clés d'objet est significatif.
Valeurs null
['users', null] == ['users', null] // true
['users', null] == ['users', 1] // false
Structure des clés de requête
Patterns recommandés
Pattern 1 : Entité + ID
['users', userId]
['posts', postId]
['products', sku]
Pattern 2 : Entité + filtres
['posts', {'status': 'published'}]
['users', {'role': 'admin', 'active': true}]
['products', {'category': 'electronics', 'inStock': true}]
Pattern 3 : Entité + ID + relation
['users', userId, 'posts']
['posts', postId, 'comments']
['orders', orderId, 'items']
Pattern 4 : Pagination
// Basée sur l'offset
['posts', {'page': pageNumber}]
// Basée sur le curseur
['messages', {'cursor': lastMessageId, 'limit': 50}]
Anti-patterns
Les objets non sérialisables provoquent des cache misses silencieux :
['users', StreamController()] // Instance non sérialisable
['data', widget] // Instance de widget
Les valeurs non déterministes provoquent un cache miss à chaque appel :
['posts', DateTime.now()] // Non déterministe
['users', Random().nextInt()] // Change à chaque appel
L'imbrication excessive réduit la lisibilité sans bénéfice :
['a', ['b', ['c', ['d', 'e']]]] // À éviter
['posts', {'status': 'published', 'limit': 10}] // Préférer une structure plate
Objets personnalisés dans les clés
Les objets personnalisés sont valides en tant que parties de clé uniquement s'ils surchargent == et hashCode.
Avec Equatable
import 'package:equatable/equatable.dart';
@immutable
class PostFilter extends Equatable {
final String status;
final int limit;
const PostFilter({required this.status, required this.limit});
@override
List<Object?> get props => [status, limit];
}
// Utilisation
final posts = await client.fetchQuery<List<Post>>(
key: ['posts', PostFilter(status: 'published', limit: 10)],
fetcher: () => fetchPosts(status: 'published', limit: 10),
);
Surcharge manuelle
@immutable
class UserQuery {
final String role;
final int minAge;
const UserQuery({required this.role, required this.minAge});
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is UserQuery &&
role == other.role &&
minAge == other.minAge;
@override
int get hashCode => Object.hash(role, minAge);
}
== et hashCode, le cache échoue silencieusement. Chaque instance d'objet est traitée comme une clé distincte, produisant un cache miss à chaque appel.La classe QoraKey (optionnel)
QoraKey est un wrapper typé qui rend la construction des clés explicite et réutilisable.
Constructeurs factory
// Clé à un seul segment
client.fetchQuery(key: QoraKey.single('settings'), fetcher: fetchSettings);
// Entité + ID
client.fetchQuery(key: QoraKey.withId('users', userId), fetcher: () => fetchUser(userId));
// Entité + map de filtres
client.fetchQuery(
key: QoraKey.withFilter('posts', {'status': 'published'}),
fetcher: () => fetchPosts(status: 'published'),
);
// Segments personnalisés
final key = QoraKey(['custom', 'key', 'parts']);
Quand utiliser QoraKey
Utiliser QoraKey quand :
- Construction d'une factory de clés partagée utilisée dans plusieurs services
- Travail en grande équipe où les APIs auto-documentées réduisent la coordination
- La sûreté du typage explicite est préférée à la concision
Utiliser une List brute quand :
- Prototypage ou écriture de requêtes ponctuelles
- La syntaxe minimale est privilégiée
Factories de clés de requête
Les factories de clés centralisées garantissent la cohérence et permettent de refactoriser la structure des clés en un seul endroit.
class QueryKeys {
QueryKeys._();
// Clés statiques (pas d'allocation sur les appels répétés)
static const settings = QoraKey(['settings']);
static const currentUser = QoraKey(['users', 'current']);
// Clés dynamiques
static QoraKey user(String id) => QoraKey.withId('users', id);
static QoraKey userPosts(String userId) =>
QoraKey(['users', userId, 'posts']);
static QoraKey posts({String? status, int? limit}) =>
QoraKey.withFilter('posts', {
if (status != null) 'status': status,
if (limit != null) 'limit': limit,
});
}
// Utilisation
await client.fetchQuery<User>(
key: QueryKeys.user('123'),
fetcher: () => fetchUser('123'),
);
await client.fetchQuery<List<Post>>(
key: QueryKeys.posts(status: 'published', limit: 10),
fetcher: () => fetchPosts(status: 'published', limit: 10),
);
Factory avec invalidation par préfixe
class PostQueryKeys {
static const _prefix = ['posts'];
static List<dynamic> get all => _prefix;
static List<dynamic> list([Map<String, dynamic>? filters]) =>
filters != null ? [..._prefix, 'list', filters] : [..._prefix, 'list'];
static List<dynamic> detail(String id) => [..._prefix, 'detail', id];
static List<dynamic> comments(String id) =>
[..._prefix, 'detail', id, 'comments'];
}
// Invalider toutes les requêtes liées aux posts après une mutation
client.invalidateWhere((key) =>
key.isNotEmpty && key[0] == 'posts'
);
Stratégies d'invalidation
Correspondance exacte
client.invalidate(['users', userId]);
Invalidation par prédicat
invalidateWhere reçoit la clé normalisée sous forme de List<dynamic> :
// Invalider toutes les requêtes utilisateurs
client.invalidateWhere((key) => key.firstOrNull == 'users');
// Invalider toutes les requêtes pour un utilisateur spécifique
client.invalidateWhere((key) =>
key.length >= 2 && key[0] == 'users' && key[1] == userId
);
Invalidation par filtre
invalidateQueries reçoit la clé, l'état courant et les dernières options pour une sélection plus fine :
// Invalider toutes les requêtes en échec utilisant un intervalle de refetch
client.invalidateQueries(
filter: (key, state, opts) =>
state is Failure && opts?.refetchInterval != null,
);
Invalidation hiérarchique
Structurer les clés de façon hiérarchique pour invalider les requêtes liées en lot :
// Invalider toutes les données associées à un utilisateur spécifique
client.invalidateWhere((key) =>
key.length >= 2 && key[0] == 'users' && key[1] == userId
);
Compatibilité entre syntaxes
List et QoraKey sont 100% interchangeables :
// Écriture avec List
await client.fetchQuery<User>(
key: ['users', 123],
fetcher: () => fetchUser(123),
);
// Cache hit avec QoraKey (même entrée)
await client.fetchQuery<User>(
key: QoraKey.withId('users', 123),
fetcher: () => throw Exception('Non appelé'),
);
// Invalidation avec l'une ou l'autre forme
client.invalidate(['users', 123]);
client.invalidate(QoraKey.withId('users', 123));
Considérations de performance
Chaque clé est copiée en profondeur et rendue immuable à l'ingestion. Modifier la liste originale après l'avoir passée n'a aucun effet :
final key = ['users', 123];
client.fetchQuery(key: key, fetcher: () => fetchUser(123));
key.add('extra'); // Sans effet : la copie interne est déjà immuable
Pour les clés statiques fréquemment référencées, les déclarer const pour éliminer les allocations répétées :
class QueryKeys {
static const settings = QoraKey(['settings']);
static const currentUser = QoraKey(['users', 'current']);
}
Conserver des clés peu profondes. Les maps nécessitent un tri pour les vérifications d'égalité. Une imbrication au-delà de trois niveaux ajoute une surcharge sans bénéfice.
Résumé des bonnes pratiques
À faire
// Primitifs
['users', 123, 'profile']
// Maps pour les filtres
['posts', {'status': 'published', 'limit': 10}]
// Structure hiérarchique pour l'invalidation en lot
['entity', id, 'relation']
// Factories centralisées
QueryKeys.user(userId)
// const pour les clés statiques
static const settings = QoraKey(['settings']);
À éviter
// Instances non sérialisables
['users', StreamController()]
// Valeurs non déterministes
['posts', DateTime.now()]
// Imbrication excessive
['a', ['b', ['c', ['d']]]]
// Objets sans surcharge ==
['filter', UnequatableClass()]
Diagnostic
Le cache rate toujours
Symptôme : chaque fetch déclenche une requête réseau.
Diagnostic : les objets personnalisés dans la clé ne surchargent pas == et hashCode.
// Vérifier l'égalité des clés
print(['users', customObject1] == ['users', customObject2]);
// Doit afficher true si les objets représentent les mêmes données
Correctif : ajouter les surcharges == et hashCode, ou utiliser Equatable.
ArgumentError sur la clé
Symptôme : ArgumentError: Key must be QoraKey or List<dynamic>
Cause : une valeur qui n'est ni List ni QoraKey a été passée comme clé.
client.fetchQuery(key: 'string', ...); // Incorrect
client.fetchQuery(key: ['string'], ...); // Correct
Refetchs inattendus
Symptôme : la requête refetch alors que la clé semble identique.
Cause : la clé contient une valeur non déterministe qui change à chaque build.
['posts', DateTime.now()] // Les rebuilds créent une nouvelle clé à chaque fois
Correctif : utiliser des valeurs stables et déterministes dans les clés.
Étapes suivantes
- États des requêtes : Machine d'état
Loading,SuccessetFailure - Cache : LRU, GC et cycle de vie du cache
- Requêtes :
fetchQuery,watchQueryetprefetch
Requêtes
Les requêtes sont le fondement de Qora. Une requête est une demande asynchrone liée à une clé, avec cache automatique, déduplication, retries et state réactif.
É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.