Concepts clés

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.

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

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

user_query.dart
@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);
}
Sans surcharge de == 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.

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

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

Copyright © 2026